diff --git a/CHANGELOG.md b/CHANGELOG.md
index 756c24d52..b67b7352d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,13 @@
+## Unreleased
+
+- https://github.com/ndtp/android-testify/pull/269
+ - `dev.testify.internal.extensions.cyan` moved to `dev.testify.extensions.cyan`
+ - Java interop for `findAnnotation` method is now available from `AnnotationExtensionsKtx`
+ - Java interop for `instrumentationPrintln` method is now available from `InstrumentationRegistryExtensionsKt`
+ - Java interop for `getModuleName` method is now available from `InstrumentationRegistryExtensionsKt`
+ - `fun Context.updateLocale(locale: Locale?): Context` is now public
+ - `fun getMetaDataBundle(context: Context): Bundle?` is now public
+
## 5.0.1
* Fix Testify plugin crash on Android Gradle Plugin 9+
diff --git a/Ext/Accessibility/src/main/java/dev/testify/accessibility/internal/AccessibilityScreenshotLifecycleObserver.kt b/Ext/Accessibility/src/main/java/dev/testify/accessibility/internal/AccessibilityScreenshotLifecycleObserver.kt
index 7b67d56d6..19da66444 100644
--- a/Ext/Accessibility/src/main/java/dev/testify/accessibility/internal/AccessibilityScreenshotLifecycleObserver.kt
+++ b/Ext/Accessibility/src/main/java/dev/testify/accessibility/internal/AccessibilityScreenshotLifecycleObserver.kt
@@ -35,10 +35,10 @@ import com.google.android.apps.common.testing.accessibility.framework.Accessibil
import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchyAndroid
import dev.testify.ScreenshotLifecycle
import dev.testify.accessibility.exception.AccessibilityErrorsException
+import dev.testify.extensions.cyan
import dev.testify.internal.extensions.TestInstrumentationRegistry
import dev.testify.internal.extensions.TestInstrumentationRegistry.instrumentationPrintln
import dev.testify.internal.extensions.TestInstrumentationRegistry.isRecordMode
-import dev.testify.internal.extensions.cyan
import dev.testify.testDescription
import java.util.Locale
diff --git a/Ext/Ktx/README.md b/Ext/Ktx/README.md
new file mode 100644
index 000000000..217600258
--- /dev/null
+++ b/Ext/Ktx/README.md
@@ -0,0 +1,60 @@
+# Testify — Android Screenshot Testing — Kotlin Extensions
+
+
+
+**Kotlin extensions for Android Testify, providing more idiomatic and helper APIs to work with screenshot testing in Android.**
+
+The new KTX library packages up a set of foundational utilities that originally lived deep inside Testify’s screenshot testing engine. These components are broadly useful for any instrumentation test suite. By extracting and stabilizing these internals, the library provides a standalone toolkit that improves the reliability, predictability, and ergonomics of your androidTest environment, even if you never call a screenshot API.
+
+Why Use Testify KTX?
+
+- Adds idiomatic Kotlin helpers around core Testify APIs, reducing boilerplate.
+- Provides a simplified set of file I/O utilities for files on the emulator SD card, `data/data` directory, or Test Storage.
+- Includes utilities for working with annotations, device identification, and test instrumentation.
+
+# Set up testify-ktx
+
+**Root build.gradle**
+
+```groovy
+plugins {
+ id("dev.testify") version "5.0.0" apply false
+}
+```
+
+**settings.gradle**
+
+Ensure that `mavenCentral()` is available in `dependencyResolutionManagement`.
+
+**Application build.gradle**
+```groovy
+dependencies {
+ androidTestImplementation "dev.testify:testify-ktx:3.2.3"
+}
+```
+
+---
+
+# License
+
+ MIT License
+
+ Copyright (c) 2026 ndtp
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
\ No newline at end of file
diff --git a/Ext/Ktx/build.gradle b/Ext/Ktx/build.gradle
new file mode 100644
index 000000000..a2130af36
--- /dev/null
+++ b/Ext/Ktx/build.gradle
@@ -0,0 +1,110 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ id 'com.android.library'
+ id 'kotlin-android'
+ id 'org.jetbrains.dokka'
+ id 'maven-publish'
+ id 'signing'
+}
+
+ext {
+ pom = [
+ publishedGroupId : 'dev.testify',
+ artifact : 'testify-ktx',
+ libraryName : 'testify-ktx',
+ libraryDescription: 'Kotlin extension methods and helpers for Android Testify',
+ siteUrl : 'https://github.com/ndtp/android-testify',
+ gitUrl : 'https://github.com/ndtp/android-testify.git',
+ licenseName : 'The MIT License',
+ licenseUrl : 'https://opensource.org/licenses/MIT',
+ author : 'ndtp'
+ ]
+}
+
+version = project.findProperty("testify_version") ?: "0.0.1-SNAPSHOT"
+group = pom.publishedGroupId
+archivesBaseName = pom.artifact
+
+android {
+ namespace "dev.testify.ktx"
+
+ lintOptions {
+ abortOnError true
+ warningsAsErrors true
+ textOutput 'stdout'
+ textReport true
+ xmlReport false
+ }
+
+ defaultConfig {
+ compileSdkVersion = libs.versions.compileSdk.get().toInteger()
+ minSdkVersion libs.versions.minSdk.get().toInteger()
+ targetSdkVersion libs.versions.targetSdk.get().toInteger()
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ libraryVariants.configureEach { variant ->
+ variant.outputs.all {
+ outputFileName = "${archivesBaseName}-${version}.aar"
+ }
+ }
+
+ testOptions {
+ unitTests.returnDefaultValues = true
+ unitTests.all {
+ testLogging {
+ events "passed", "skipped", "failed", "standardOut", "standardError"
+ outputs.upToDateWhen { false }
+ showStandardStreams = true
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_21
+ targetCompatibility JavaVersion.VERSION_21
+ }
+
+ dependencies {
+ implementation libs.androidx.monitor
+ implementation libs.androidx.rules
+ implementation libs.androidx.test.storage
+ implementation libs.androidx.uiautomator
+ implementation libs.core.ktx
+ implementation libs.material
+
+ testImplementation libs.mockk
+
+ androidTestImplementation libs.androidx.junit
+ androidTestImplementation libs.androidx.runner
+ }
+ packagingOptions {
+ resources {
+ excludes += [
+ 'MANIFEST.MF',
+ 'META-INF/LICENSE.md',
+ 'META-INF/LICENSE-notice.md'
+ ]
+ }
+ }
+ publishing {
+ singleVariant("release") {
+ // if you don't want sources/javadoc, remove these lines
+ withSourcesJar()
+ withJavadocJar()
+ }
+ }
+}
+
+afterEvaluate {
+ apply from: "../../publish.build.gradle"
+}
+
+tasks.withType(KotlinJvmCompile).configureEach {
+ compilerOptions {
+ allWarningsAsErrors.set(true)
+ jvmTarget.set(JvmTarget.JVM_21)
+ }
+}
diff --git a/Ext/Ktx/src/androidTest/assets/screenshots/29-1080x2220@440dp-en_US/test.png b/Ext/Ktx/src/androidTest/assets/screenshots/29-1080x2220@440dp-en_US/test.png
new file mode 100644
index 000000000..3efbe0fc7
Binary files /dev/null and b/Ext/Ktx/src/androidTest/assets/screenshots/29-1080x2220@440dp-en_US/test.png differ
diff --git a/Library/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt b/Ext/Ktx/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt
similarity index 87%
rename from Library/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt
rename to Ext/Ktx/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt
index f6d8237b3..45604b5c7 100644
--- a/Library/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt
+++ b/Ext/Ktx/src/androidTest/java/dev/testify/ScreenshotUtilityTest.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -26,11 +26,13 @@
package dev.testify
+import android.app.Activity
+import android.graphics.Bitmap
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
-import dev.testify.core.processor.capture.createBitmapFromDrawingCache
+import dev.testify.ktx.TestActivity
import dev.testify.output.getDestination
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -61,14 +63,17 @@ class ScreenshotUtilityTest {
@Test
fun createBitmapFromActivity() {
val activity = testActivityRule.activity
- val rootView = activity.findViewById(R.id.test_root_view)
+ val rootView = activity.findViewById(android.R.id.content)
val destination = getDestination(context = activity, fileName = "testing")
val bitmapFile = destination.file
val capturedBitmap = createBitmapFromActivity(
activity = activity,
fileName = "testing",
- captureMethod = ::createBitmapFromDrawingCache,
+ captureMethod = { activity: Activity, view: View? ->
+ val view: View = view ?: activity.window.decorView
+ Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ },
screenshotView = rootView
)
assertNotNull(capturedBitmap)
diff --git a/Ext/Ktx/src/debug/AndroidManifest.xml b/Ext/Ktx/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..f1fafe1d9
--- /dev/null
+++ b/Ext/Ktx/src/debug/AndroidManifest.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Ext/Ktx/src/debug/java/dev/testify/ktx/TestActivity.kt b/Ext/Ktx/src/debug/java/dev/testify/ktx/TestActivity.kt
new file mode 100644
index 000000000..0aedf0f1e
--- /dev/null
+++ b/Ext/Ktx/src/debug/java/dev/testify/ktx/TestActivity.kt
@@ -0,0 +1,14 @@
+package dev.testify.ktx
+
+import android.app.Activity
+import android.os.Bundle
+
+/**
+ * This is a test Activity that is used to test the Testify library.
+ */
+class TestActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ }
+}
diff --git a/Library/src/main/java/dev/testify/CaptureMethod.kt b/Ext/Ktx/src/main/java/dev/testify/CaptureMethod.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/CaptureMethod.kt
rename to Ext/Ktx/src/main/java/dev/testify/CaptureMethod.kt
index 477acd2b8..220a869e0 100644
--- a/Library/src/main/java/dev/testify/CaptureMethod.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/CaptureMethod.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2022 ndtp
+ * Copyright (c) 2022-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/ScreenshotUtility.kt b/Ext/Ktx/src/main/java/dev/testify/ScreenshotUtility.kt
similarity index 95%
rename from Library/src/main/java/dev/testify/ScreenshotUtility.kt
rename to Ext/Ktx/src/main/java/dev/testify/ScreenshotUtility.kt
index bbb3a40cb..4852099fb 100644
--- a/Library/src/main/java/dev/testify/ScreenshotUtility.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/ScreenshotUtility.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -60,7 +60,7 @@ val preferredBitmapOptions: BitmapFactory.Options
*
* @param context The [Context] to use when writing the bitmap to disk.
* @param bitmap The [Bitmap] to write to disk. If null, this function will return false.
- * @param destination The [Destination] to write the bitmap to.
+ * @param destination The [dev.testify.output.Destination] to write the bitmap to.
*
* @throws Exception if the destination cannot be found.
*
@@ -130,7 +130,7 @@ fun loadBaselineBitmapForComparison(
*
* @param activity The [Activity] instance to capture.
* @param fileName The name to use when writing the captured image to disk.
- * @param captureMethod a [CaptureMethod] that will return a [Bitmap] from the provided [Activity] and [View]
+ * @param captureMethod a [dev.testify.CaptureMethod] that will return a [Bitmap] from the provided [Activity] and [View]
* @param screenshotView A [View] found in the [activity]'s view hierarchy.
* If screenshotView is null, defaults to activity.window.decorView.
*
@@ -182,7 +182,7 @@ fun loadBitmapFromFile(outputPath: String, preferredBitmapOptions: BitmapFactory
/**
* Delete the Bitmap [File] specified by [destination].
*
- * @param destination The [Destination] to delete.
+ * @param destination The [dev.testify.output.Destination] to delete.
*
* @return true if the file was successfully deleted, false otherwise.
*/
diff --git a/Ext/Ktx/src/main/java/dev/testify/annotation/AnnotationExtensions.kt b/Ext/Ktx/src/main/java/dev/testify/annotation/AnnotationExtensions.kt
new file mode 100644
index 000000000..cf03fbd05
--- /dev/null
+++ b/Ext/Ktx/src/main/java/dev/testify/annotation/AnnotationExtensions.kt
@@ -0,0 +1,62 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2022-2026 ndtp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+@file:JvmName("AnnotationExtensionsKtx")
+
+package dev.testify.annotation
+
+/**
+ * Find the first [Annotation] in the given [Collection] which is of type [T]
+ *
+ * @return Annotation of type T
+ */
+inline fun Collection.findAnnotation(): T? =
+ this.find { it is T } as? T
+
+/**
+ * Find the first [Annotation] in the given [Collection] which is of type [T]
+ *
+ * @return Annotation of type T
+ */
+fun Collection.findAnnotation(clazz: Class): T? =
+ this.find { clazz.isInstance(it) }?.let { clazz.cast(it) }
+
+/**
+ * Find the first [Annotation] in the given [Collection] which has the given [name]
+ *
+ * @param name - The qualified class name of the requested annotation
+ *
+ * @return Annotation of type T
+ */
+inline fun Collection.findAnnotation(name: String): T? =
+ this.find { it.annotationClass.qualifiedName == name } as? T
+
+/**
+ * Find the first [Annotation] in the given [Collection] which has the given [name]
+ *
+ * @param name - The qualified class name of the requested annotation
+ *
+ * @return Annotation of type T
+ */
+fun Collection.findAnnotation(name: String, clazz: Class): T? =
+ this.find { it.annotationClass.qualifiedName == name && clazz.isInstance(it) }?.let { clazz.cast(it) }
diff --git a/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt b/Ext/Ktx/src/main/java/dev/testify/core/DeviceIdentifier.kt
similarity index 98%
rename from Library/src/main/java/dev/testify/core/DeviceIdentifier.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/DeviceIdentifier.kt
index 16be0b3da..9c41781a8 100644
--- a/Library/src/main/java/dev/testify/core/DeviceIdentifier.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/DeviceIdentifier.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -31,6 +31,7 @@ import android.view.WindowManager
import dev.testify.internal.extensions.languageTag
import dev.testify.internal.helpers.buildVersionSdkInt
import java.util.Locale
+import kotlin.text.iterator
/**
* A typealias for the test class and test name.
diff --git a/Library/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt b/Ext/Ktx/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt
index bd5c468ae..22fdad24d 100644
--- a/Library/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/exception/RootViewNotFoundException.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/Library/src/main/java/dev/testify/core/exception/TestifyException.kt b/Ext/Ktx/src/main/java/dev/testify/core/exception/TestifyException.kt
similarity index 93%
rename from Library/src/main/java/dev/testify/core/exception/TestifyException.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/exception/TestifyException.kt
index acc3e92d3..f33371c74 100644
--- a/Library/src/main/java/dev/testify/core/exception/TestifyException.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/exception/TestifyException.kt
@@ -1,7 +1,8 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
+ * Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Ext/Ktx/src/main/java/dev/testify/core/processor/MemoryHelpers.kt b/Ext/Ktx/src/main/java/dev/testify/core/processor/MemoryHelpers.kt
new file mode 100644
index 000000000..c788d557b
--- /dev/null
+++ b/Ext/Ktx/src/main/java/dev/testify/core/processor/MemoryHelpers.kt
@@ -0,0 +1,109 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2026 ndtp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package dev.testify.core.processor
+
+import android.annotation.SuppressLint
+import android.app.ActivityManager
+import android.app.ActivityManager.MemoryInfo
+import android.content.Context
+import android.content.Context.ACTIVITY_SERVICE
+import android.os.Debug
+import androidx.test.platform.app.InstrumentationRegistry
+
+/**
+ * Formats a Long size to be in the form of bytes, kilobytes, megabytes, etc.
+ */
+private fun Long.format() = this.toDouble().format()
+
+@SuppressLint("DefaultLocale")
+private fun Double.format() =
+ when {
+ this < 1024.0 -> "${this.toLong()} B"
+ (this >= 1024.0 && this < 1048576.0) -> "${String.format("%.1f", this / 1024.0)} KB"
+ (this >= 1048576.0 && this < 1073741824.0) -> "${String.format("%.1f", this / 1048576.0)} MB"
+ else -> "${String.format("%.1f", this / 1073741824.0)} GB"
+ }
+
+fun formatMemoryState(): String {
+ val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
+ val instrumentationContext = InstrumentationRegistry.getInstrumentation().context
+
+ return StringBuilder()
+ .appendLine("Target Context:")
+ .append("- ")
+ .appendLine(formatMemoryState(targetContext))
+ .appendLine("Instrumentation Context:")
+ .append("- ")
+ .appendLine(formatMemoryState(instrumentationContext))
+ .toString()
+}
+
+/**
+ * Returns a print-friendly string representation of the current system memory state
+ */
+private fun formatMemoryState(context: Context): String {
+ val result = mutableListOf()
+
+ val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
+ with(activityManager) {
+ // The approximate per-application memory class of the current device.
+ result.add("memoryClass: $memoryClass MB")
+ // The approximate per-application memory class of the current device when an application is running with a large heap
+ result.add("largeMemoryClass: $largeMemoryClass MB")
+ }
+
+ val memoryInfo = MemoryInfo().apply {
+ activityManager.getMemoryInfo(this)
+ }
+ with(memoryInfo) {
+ // The available memory on the system
+ result.add("avail: ${availMem.format()}")
+ // The total memory accessible by the kernel. This is basically the RAM size of the device
+ result.add("total: ${totalMem.format()}")
+ // The threshold of availMem at which we consider memory to be low
+ result.add("threshold: ${threshold.format()}")
+ // Set to true if the system considers itself to currently be in a low memory situation.
+ result.add("isLow: $lowMemory")
+ }
+
+ with(Runtime.getRuntime()) {
+ // The maximum amount of memory that the virtual machine will attempt to use, measured in bytes
+ result.add("heapSize: ${maxMemory().format()}")
+ // The total amount of memory currently available for current and future objects, measured in bytes.
+ result.add("runtime total: ${totalMemory().format()}")
+ // An approximation to the total amount of memory currently available for future allocated objects, measured in bytes
+ result.add("free: ${freeMemory().format()}")
+ // Used memory = total memory available - free memory
+ result.add("used: ${(totalMemory() - freeMemory()).format()}")
+ }
+
+ // The size of the native heap in bytes.
+ result.add("nativeHeapSize: ${Debug.getNativeHeapSize().format()}")
+ // Returns the amount of free memory in the native heap.
+ result.add("nativeFree: ${Debug.getNativeHeapFreeSize().format()}")
+ // Returns the amount of allocated memory in the native heap.
+ result.add("nativeUsed: ${Debug.getNativeHeapAllocatedSize().format()}")
+
+ return result.joinToString(", ")
+}
diff --git a/Library/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt
index fcb61d12e..c34de7ece 100644
--- a/Library/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/CanvasCapture.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/Library/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt
index 9ad66d37f..a2b34ffc4 100644
--- a/Library/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/DrawingCacheCapture.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/Library/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt
similarity index 98%
rename from Library/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt
rename to Ext/Ktx/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt
index 17dea811e..80166837a 100644
--- a/Library/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/core/processor/capture/PixelCopyCapture.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/Ext/Ktx/src/main/java/dev/testify/extensions/InstrumentationRegistryExtensions.kt b/Ext/Ktx/src/main/java/dev/testify/extensions/InstrumentationRegistryExtensions.kt
new file mode 100644
index 000000000..71c470873
--- /dev/null
+++ b/Ext/Ktx/src/main/java/dev/testify/extensions/InstrumentationRegistryExtensions.kt
@@ -0,0 +1,73 @@
+/*
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2022-2026 ndtp
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+package dev.testify.extensions
+
+import android.app.Instrumentation
+import android.os.Bundle
+import androidx.test.platform.app.InstrumentationRegistry
+import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport
+import dev.testify.internal.helpers.ManifestPlaceholder
+import dev.testify.internal.helpers.getMetaDataValue
+
+/**
+ * Prints a string to the instrumentation output stream (test log).
+ *
+ * @param str - A string to print to the instrumentation stream.
+ */
+fun instrumentationPrintln(str: String) {
+ InstrumentationRegistry.getInstrumentation().sendStatus(
+ 0,
+ Bundle().apply {
+ putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\n" + str)
+ }
+ )
+}
+
+/**
+ * Get the gradle project name of the module which contains the currently running test.
+ * This requires the argument "moduleName" to be specified. Tests run via Android Studio do not specify the
+ * "moduleName" argument and so this method will return an empty string.
+ *
+ * @return Gradle module name if available. e.g. :Sample
+ * Empty string otherwise.
+ */
+@ExcludeFromJacocoGeneratedReport
+fun getModuleName(instrumentationRegistryArguments: Bundle): String {
+ val name = if (instrumentationRegistryArguments.containsKey("moduleName")) {
+ instrumentationRegistryArguments.getString(
+ "moduleName"
+ )!! + ":"
+ } else {
+ ""
+ }
+ return name.ifEmpty { ManifestPlaceholder.Module.getMetaDataValue() ?: "" }
+}
+
+private const val ESC_CYAN = "${27.toChar()}[36m"
+private const val ESC_RESET = "${27.toChar()}[0m"
+
+/**
+ * Returns a string wrapped in ANSI cyan escape characters.
+ */
+fun String.cyan() = "$ESC_CYAN$this$ESC_RESET"
diff --git a/Library/src/main/java/dev/testify/extensions/ViewExtensions.kt b/Ext/Ktx/src/main/java/dev/testify/extensions/ViewExtensions.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/extensions/ViewExtensions.kt
rename to Ext/Ktx/src/main/java/dev/testify/extensions/ViewExtensions.kt
index 1e787aaf2..23c4e9222 100644
--- a/Library/src/main/java/dev/testify/extensions/ViewExtensions.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/extensions/ViewExtensions.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2020 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt b/Ext/Ktx/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt
similarity index 98%
rename from Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt
index 4ff30f7cd..e48f9cd35 100644
--- a/Library/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/annotation/ExcludeFromJacocoGeneratedReport.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt b/Ext/Ktx/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt
similarity index 90%
rename from Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt
index cd0d885c8..d68f57c48 100644
--- a/Library/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/extensions/LocaleExtensions.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2019 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -26,11 +26,11 @@
package dev.testify.internal.extensions
import android.annotation.SuppressLint
-import android.annotation.TargetApi
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.os.LocaleList
+import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import dev.testify.internal.helpers.buildVersionSdkInt
import java.util.Locale
@@ -41,7 +41,8 @@ import java.util.Locale
* @param locale The locale to update to.
* @return The updated context.
*/
-internal fun Context.updateLocale(locale: Locale?): Context {
+@SuppressLint("NewApi")
+fun Context.updateLocale(locale: Locale?): Context {
if (locale == null) return this
return if (buildVersionSdkInt() >= Build.VERSION_CODES.N) {
@@ -60,8 +61,8 @@ internal fun Context.updateLocale(locale: Locale?): Context {
* @return The updated context.
*/
@VisibleForTesting
-@TargetApi(Build.VERSION_CODES.N)
-internal fun Context.updateResources(locale: Locale): Context {
+@RequiresApi(Build.VERSION_CODES.N)
+fun Context.updateResources(locale: Locale): Context {
val configuration = Configuration(this.resources.configuration)
val localeList = LocaleList(locale)
LocaleList.setDefault(localeList)
@@ -79,7 +80,7 @@ internal fun Context.updateResources(locale: Locale): Context {
*/
@VisibleForTesting
@Suppress("DEPRECATION")
-internal fun Context.updateResourcesLegacy(locale: Locale): Context {
+fun Context.updateResourcesLegacy(locale: Locale): Context {
Locale.setDefault(locale)
val configuration = Configuration(this.resources.configuration)
configuration.locale = locale
diff --git a/Library/src/main/java/dev/testify/internal/helpers/AssetLoader.kt b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/AssetLoader.kt
similarity index 98%
rename from Library/src/main/java/dev/testify/internal/helpers/AssetLoader.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/helpers/AssetLoader.kt
index 0ca8fe35d..c1508bf7e 100644
--- a/Library/src/main/java/dev/testify/internal/helpers/AssetLoader.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/AssetLoader.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2022 ndtp
+ * Copyright (c) 2022-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/BuildVersion.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/helpers/BuildVersion.kt
index e44126315..dc9a0d72c 100644
--- a/Library/src/main/java/dev/testify/internal/helpers/BuildVersion.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/BuildVersion.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/FindRootView.kt
similarity index 93%
rename from Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/helpers/FindRootView.kt
index 1cda700ba..3a0e204da 100644
--- a/Library/src/main/java/dev/testify/internal/helpers/FindRootView.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/FindRootView.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -34,7 +34,7 @@ import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport
*
* @param rootViewId The id of the root view.
* @return The root view.
- * @throws RootViewNotFoundException If the root view is not found.
+ * @throws dev.testify.core.exception.RootViewNotFoundException If the root view is not found.
*/
@ExcludeFromJacocoGeneratedReport
fun Activity.findRootView(@IdRes rootViewId: Int): ViewGroup =
diff --git a/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt
index 12008b786..4cf99bdcd 100644
--- a/Library/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/IsRunningOnUiThread.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt
rename to Ext/Ktx/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt
index 83c0c2146..7ec2525ca 100644
--- a/Library/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/internal/helpers/ManifestHelpers.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023-2024 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -71,7 +71,7 @@ sealed class ManifestPlaceholder(val key: String) {
* @return The [Bundle] of meta data, or null if it does not exist.
*/
@ExcludeFromJacocoGeneratedReport
-internal fun getMetaDataBundle(context: Context): Bundle? {
+fun getMetaDataBundle(context: Context): Bundle? {
val applicationInfo = context.packageManager?.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
return applicationInfo?.metaData
}
diff --git a/Library/src/main/java/dev/testify/output/DataDirectoryDestination.kt b/Ext/Ktx/src/main/java/dev/testify/output/DataDirectoryDestination.kt
similarity index 97%
rename from Library/src/main/java/dev/testify/output/DataDirectoryDestination.kt
rename to Ext/Ktx/src/main/java/dev/testify/output/DataDirectoryDestination.kt
index f4ad639e4..95c938c81 100644
--- a/Library/src/main/java/dev/testify/output/DataDirectoryDestination.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/output/DataDirectoryDestination.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -136,5 +136,5 @@ open class DataDirectoryDestination(
/**
* Exception to throw when the destination is not found or could not be created.
*/
-internal class DataDirectoryDestinationNotFoundException(path: String) :
+class DataDirectoryDestinationNotFoundException(path: String) :
TestifyException("NO_DIRECTORY", "\n\n* Could not find or create path {$path}")
diff --git a/Library/src/main/java/dev/testify/output/Destination.kt b/Ext/Ktx/src/main/java/dev/testify/output/Destination.kt
similarity index 99%
rename from Library/src/main/java/dev/testify/output/Destination.kt
rename to Ext/Ktx/src/main/java/dev/testify/output/Destination.kt
index 3eca77a99..5904073b7 100644
--- a/Library/src/main/java/dev/testify/output/Destination.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/output/Destination.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/src/main/java/dev/testify/output/OutputFileUtility.kt b/Ext/Ktx/src/main/java/dev/testify/output/OutputFileUtility.kt
similarity index 92%
rename from Library/src/main/java/dev/testify/output/OutputFileUtility.kt
rename to Ext/Ktx/src/main/java/dev/testify/output/OutputFileUtility.kt
index d21a149a0..1494e4358 100644
--- a/Library/src/main/java/dev/testify/output/OutputFileUtility.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/output/OutputFileUtility.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2022 ndtp
+ * Modified work copyright (c) 2022-2026 ndtp
* Original work copyright (c) 2021 Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -27,12 +27,12 @@ package dev.testify.output
/**
* The default screenshot directory name
*/
-internal const val SCREENSHOT_DIR = "screenshots"
+const val SCREENSHOT_DIR = "screenshots"
/**
* The default screenshot file extension
*/
-internal const val PNG_EXTENSION = ".png"
+const val PNG_EXTENSION = ".png"
/**
* Construct a path to the baseline image file
diff --git a/Library/src/main/java/dev/testify/output/SdCardDestination.kt b/Ext/Ktx/src/main/java/dev/testify/output/SdCardDestination.kt
similarity index 96%
rename from Library/src/main/java/dev/testify/output/SdCardDestination.kt
rename to Ext/Ktx/src/main/java/dev/testify/output/SdCardDestination.kt
index 275799e1c..ed6edce13 100644
--- a/Library/src/main/java/dev/testify/output/SdCardDestination.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/output/SdCardDestination.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -107,7 +107,7 @@ open class SdCardDestination(
/**
* Get the path to the output directory
- * The default implementation uses the [DeviceStringFormatter] to format the device string
+ * The default implementation uses the [dev.testify.core.DeviceStringFormatter] to format the device string
* as the directory name.
*
* @param context The [Context] to use
@@ -147,7 +147,7 @@ open class SdCardDestination(
/**
* Exception to throw when the destination is not found
*/
-internal class SdCardDestinationNotFoundException(path: String) :
+class SdCardDestinationNotFoundException(path: String) :
TestifyException(
"NO_SD_CARD",
"""
diff --git a/Library/src/main/java/dev/testify/output/TestStorageDestination.kt b/Ext/Ktx/src/main/java/dev/testify/output/TestStorageDestination.kt
similarity index 98%
rename from Library/src/main/java/dev/testify/output/TestStorageDestination.kt
rename to Ext/Ktx/src/main/java/dev/testify/output/TestStorageDestination.kt
index a77331b0f..dba189fc9 100644
--- a/Library/src/main/java/dev/testify/output/TestStorageDestination.kt
+++ b/Ext/Ktx/src/main/java/dev/testify/output/TestStorageDestination.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Modified work copyright (c) 2023-2025 ndtp
+ * Modified work copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -155,7 +155,7 @@ class TestStorageDestination(
/**
* Exception to throw when the [TestStorage] service is not found.
*/
-internal class TestStorageNotFoundException :
+class TestStorageNotFoundException :
TestifyException(
"NO_TEST_STORAGE",
"""
diff --git a/Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt b/Ext/Ktx/src/test/java/dev/testify/core/DeviceIdentifierTest.kt
similarity index 100%
rename from Library/src/test/java/dev/testify/core/DeviceIdentifierTest.kt
rename to Ext/Ktx/src/test/java/dev/testify/core/DeviceIdentifierTest.kt
diff --git a/Library/src/test/java/dev/testify/output/DestinationTest.kt b/Ext/Ktx/src/test/java/dev/testify/output/DestinationTest.kt
similarity index 99%
rename from Library/src/test/java/dev/testify/output/DestinationTest.kt
rename to Ext/Ktx/src/test/java/dev/testify/output/DestinationTest.kt
index 539084cfc..ff763e971 100644
--- a/Library/src/test/java/dev/testify/output/DestinationTest.kt
+++ b/Ext/Ktx/src/test/java/dev/testify/output/DestinationTest.kt
@@ -1,7 +1,7 @@
/*
* The MIT License (MIT)
*
- * Copyright (c) 2023 ndtp
+ * Copyright (c) 2023-2026 ndtp
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
diff --git a/Library/build.gradle b/Library/build.gradle
index 1cd1e2024..e6be21b23 100644
--- a/Library/build.gradle
+++ b/Library/build.gradle
@@ -30,6 +30,9 @@ archivesBaseName = pom.artifact
jacoco { toolVersion = "0.8.10" }
android {
+ kotlinOptions {
+ jvmTarget = '21'
+ }
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
@@ -68,12 +71,13 @@ android {
}
dependencies {
+ api project(":Ktx")
+
implementation libs.androidx.core.ktx
implementation libs.androidx.espresso.core
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.rules
implementation libs.androidx.runner
- implementation libs.androidx.test.storage
implementation libs.colormath
implementation libs.kotlinx.coroutines.android
diff --git a/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt b/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt
index c3cb31f67..c3c7f5033 100644
--- a/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt
+++ b/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt
@@ -70,7 +70,7 @@ class ImageBufferTest {
@Test(expected = LowMemoryException::class)
fun allocate_fails_on_oom() {
val activityManager = getInstrumentation().targetContext.getSystemService(ACTIVITY_SERVICE) as ActivityManager
- val requestedSize: Int = activityManager.memoryClass * 1_048_576 / 2
+ val requestedSize: Int = activityManager.memoryClass * 1_048_576 * 2
ImageBuffers.allocate(width = 1, height = requestedSize, allocateDiffBuffer = false)
}
diff --git a/Library/src/main/java/dev/testify/annotation/AnnotationExtensions.kt b/Library/src/main/java/dev/testify/annotation/AnnotationExtensions.kt
index 87afc8095..ea638ebba 100644
--- a/Library/src/main/java/dev/testify/annotation/AnnotationExtensions.kt
+++ b/Library/src/main/java/dev/testify/annotation/AnnotationExtensions.kt
@@ -32,24 +32,6 @@ import androidx.test.platform.app.InstrumentationRegistry
fun getScreenshotAnnotationName(): String =
InstrumentationRegistry.getArguments().getString("annotation", ScreenshotInstrumentation::class.qualifiedName)
-/**
- * Find the first [Annotation] in the given [Collection] which is of type [T]
- *
- * @return Annotation of type T
- */
-inline fun Collection.findAnnotation(): T? =
- this.find { it is T } as? T
-
-/**
- * Find the first [Annotation] in the given [Collection] which has the given [name]
- *
- * @param name - The qualified class name of the requested annotation
- *
- * @return Annotation of type T
- */
-inline fun Collection.findAnnotation(name: String): T? =
- this.find { it.annotationClass.qualifiedName == name } as? T
-
/**
* Get the [ScreenshotInstrumentation] instance associated with the test method
*
diff --git a/Library/src/main/java/dev/testify/core/logic/AssertSame.kt b/Library/src/main/java/dev/testify/core/logic/AssertSame.kt
index 4db5bb5b7..11798522a 100644
--- a/Library/src/main/java/dev/testify/core/logic/AssertSame.kt
+++ b/Library/src/main/java/dev/testify/core/logic/AssertSame.kt
@@ -46,8 +46,8 @@ import dev.testify.core.formatDeviceString
import dev.testify.core.processor.capture.createBitmapFromDrawingCache
import dev.testify.core.processor.diff.HighContrastDiff
import dev.testify.deleteBitmap
+import dev.testify.extensions.cyan
import dev.testify.internal.extensions.TestInstrumentationRegistry
-import dev.testify.internal.extensions.cyan
import dev.testify.internal.helpers.ActivityProvider
import dev.testify.internal.helpers.ResourceWrapper
import dev.testify.internal.helpers.findRootView
diff --git a/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt b/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt
index 17d8477e1..79d4f9d92 100644
--- a/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt
+++ b/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt
@@ -23,12 +23,6 @@
*/
package dev.testify.core.processor
-import android.annotation.SuppressLint
-import android.app.ActivityManager
-import android.app.ActivityManager.MemoryInfo
-import android.content.Context
-import android.content.Context.ACTIVITY_SERVICE
-import android.os.Debug
import android.util.Log
import androidx.annotation.IntRange
import androidx.test.platform.app.InstrumentationRegistry
@@ -147,80 +141,3 @@ internal fun allocateSafely(capacity: Int, retry: Boolean = true): IntBuffer {
}
}
}
-
-/**
- * Formats a Long size to be in the form of bytes, kilobytes, megabytes, etc.
- */
-private fun Long.format() = this.toDouble().format()
-
-@SuppressLint("DefaultLocale")
-private fun Double.format() =
- when {
- this < 1024.0 -> "${this.toLong()} B"
- (this >= 1024.0 && this < 1048576.0) -> "${String.format("%.1f", this / 1024.0)} KB"
- (this >= 1048576.0 && this < 1073741824.0) -> "${String.format("%.1f", this / 1048576.0)} MB"
- else -> "${String.format("%.1f", this / 1073741824.0)} GB"
- }
-
-internal fun formatMemoryState(): String {
- val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
- val instrumentationContext = InstrumentationRegistry.getInstrumentation().context
-
- return StringBuilder()
- .appendLine("Target Context:")
- .append("- ")
- .appendLine(formatMemoryState(targetContext))
- .appendLine("Instrumentation Context:")
- .append("- ")
- .appendLine(formatMemoryState(instrumentationContext))
- .toString()
-}
-
-/**
- * Returns a print-friendly string representation of the current system memory state
- */
-private fun formatMemoryState(context: Context): String {
- val result = mutableListOf()
-
- val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
- with(activityManager) {
- // The approximate per-application memory class of the current device.
- result.add("memoryClass: $memoryClass MB")
- // The approximate per-application memory class of the current device when an application is running with a large heap
- result.add("largeMemoryClass: $largeMemoryClass MB")
- }
-
- val memoryInfo = MemoryInfo().apply {
- activityManager.getMemoryInfo(this)
- }
- with(memoryInfo) {
- // The available memory on the system
- result.add("avail: ${availMem.format()}")
- // The total memory accessible by the kernel. This is basically the RAM size of the device
- result.add("total: ${totalMem.format()}")
- // The threshold of availMem at which we consider memory to be low
- result.add("threshold: ${threshold.format()}")
- // Set to true if the system considers itself to currently be in a low memory situation.
- result.add("isLow: $lowMemory")
- }
-
- with(Runtime.getRuntime()) {
- // The maximum amount of memory that the virtual machine will attempt to use, measured in bytes
- result.add("heapSize: ${maxMemory().format()}")
- // The total amount of memory currently available for current and future objects, measured in bytes.
- result.add("runtime total: ${totalMemory().format()}")
- // An approximation to the total amount of memory currently available for future allocated objects, measured in bytes
- result.add("free: ${freeMemory().format()}")
- // Used memory = total memory available - free memory
- result.add("used: ${(totalMemory() - freeMemory()).format()}")
- }
-
- // The size of the native heap in bytes.
- result.add("nativeHeapSize: ${Debug.getNativeHeapSize().format()}")
- // Returns the amount of free memory in the native heap.
- result.add("nativeFree: ${Debug.getNativeHeapFreeSize().format()}")
- // Returns the amount of allocated memory in the native heap.
- result.add("nativeUsed: ${Debug.getNativeHeapAllocatedSize().format()}")
-
- return result.joinToString(", ")
-}
diff --git a/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt b/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt
index 3b072854c..b7f68889d 100644
--- a/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt
+++ b/Library/src/main/java/dev/testify/internal/extensions/InstrumentationRegistryExtensions.kt
@@ -23,8 +23,6 @@
*/
package dev.testify.internal.extensions
-import android.app.Instrumentation
-import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import dev.testify.internal.annotation.ExcludeFromJacocoGeneratedReport
import dev.testify.internal.helpers.ManifestPlaceholder
@@ -52,14 +50,7 @@ object TestInstrumentationRegistry {
*
* @param str - A string to print to the instrumentation stream.
*/
- fun instrumentationPrintln(str: String) {
- InstrumentationRegistry.getInstrumentation().sendStatus(
- 0,
- Bundle().apply {
- putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\n" + str)
- }
- )
- }
+ fun instrumentationPrintln(str: String) = dev.testify.extensions.instrumentationPrintln(str)
/**
* Get the gradle project name of the module which contains the currently running test.
@@ -70,11 +61,7 @@ object TestInstrumentationRegistry {
* Empty string otherwise.
*/
@ExcludeFromJacocoGeneratedReport
- fun getModuleName(): String {
- val extras = InstrumentationRegistry.getArguments()
- val name = if (extras.containsKey("moduleName")) extras.getString("moduleName")!! + ":" else ""
- return name.ifEmpty { ManifestPlaceholder.Module.getMetaDataValue() ?: "" }
- }
+ fun getModuleName(): String = dev.testify.extensions.getModuleName(InstrumentationRegistry.getArguments())
}
/**
@@ -82,11 +69,3 @@ object TestInstrumentationRegistry {
*/
fun isInvokedFromPlugin(): Boolean =
InstrumentationRegistry.getArguments().containsKey("annotation")
-
-private const val ESC_CYAN = "${27.toChar()}[36m"
-private const val ESC_RESET = "${27.toChar()}[0m"
-
-/**
- * Returns a string wrapped in ANSI cyan escape characters.
- */
-fun String.cyan() = "$ESC_CYAN$this$ESC_RESET"
diff --git a/Samples/Flix/FlixLibrary/build.gradle b/Samples/Flix/FlixLibrary/build.gradle
index 2ce85dfd0..7cc502ad8 100644
--- a/Samples/Flix/FlixLibrary/build.gradle
+++ b/Samples/Flix/FlixLibrary/build.gradle
@@ -81,6 +81,7 @@ dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.08.01')
implementation composeBom
//noinspection UseTomlInstead
+ implementation "androidx.appcompat:appcompat:1.7.1"
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.9.3"
implementation "androidx.test.espresso.idling:idling-concurrent:3.7.0"
diff --git a/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml b/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml
index 568741e54..ad690b49a 100644
--- a/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml
+++ b/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml
@@ -1,2 +1,4 @@
-
\ No newline at end of file
+
+
+
diff --git a/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml b/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml
index e6819934a..500643412 100644
--- a/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml
+++ b/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml
@@ -1,7 +1,7 @@
-
+
-
\ No newline at end of file
+
+
+
diff --git a/Samples/Flix/src/debug/AndroidManifest.xml b/Samples/Flix/src/debug/AndroidManifest.xml
index 1c389432b..6ba5de169 100644
--- a/Samples/Flix/src/debug/AndroidManifest.xml
+++ b/Samples/Flix/src/debug/AndroidManifest.xml
@@ -1,7 +1,7 @@
-
+
-
+
diff --git a/Samples/Legacy/src/main/AndroidManifest.xml b/Samples/Legacy/src/main/AndroidManifest.xml
index fcfedb78b..ee53637fa 100644
--- a/Samples/Legacy/src/main/AndroidManifest.xml
+++ b/Samples/Legacy/src/main/AndroidManifest.xml
@@ -10,7 +10,8 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
- tools:ignore="GoogleAppIndexingWarning">
+ tools:ignore="GoogleAppIndexingWarning"
+ android:largeHeap="true">
diff --git a/bitrise.yml b/bitrise.yml
index 7181f4003..aa8b90d7b 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -24,6 +24,7 @@ stages:
- test_accessibility_ext: {}
- test_compose_ext: {}
- test_fullscreen_ext: {}
+ - test_ktx: {}
workflows:
evaluate_build:
@@ -35,6 +36,13 @@ workflows:
- auth_token: $GITHUB_TOKEN
- status_identifier: "Library"
- pipeline_build_url: "$BITRISE_BUILD_URL"
+ - github-status@3:
+ run_if: .IsCI
+ inputs:
+ - set_specific_status: "pending"
+ - auth_token: $GITHUB_TOKEN
+ - status_identifier: "Ktx"
+ - pipeline_build_url: "$BITRISE_BUILD_URL"
- github-status@3:
run_if: .IsCI
inputs:
@@ -86,7 +94,7 @@ workflows:
- pipeline_build_url: "$BITRISE_BUILD_URL"
meta:
bitrise.io:
- stack: ubuntu-noble-24.04-bitrise-2025-android
+ stack: linux-docker-android-22.04
machine_type_id: standard
_emulatorSetup:
@@ -208,6 +216,41 @@ workflows:
- _globalSetup
- _emulatorSetup
+ test_ktx:
+ steps:
+ - gradle-runner@2:
+ inputs:
+ - gradlew_path: "./gradlew"
+ - gradle_task: Ktx:ktlintCheck
+ title: KtLint
+ - android-unit-test@1:
+ inputs:
+ - project_location: "$PROJECT_LOCATION"
+ - variant: Debug
+ - module: Ktx
+ - cache_level: none
+ - gradle-runner@2:
+ inputs:
+ - gradlew_path: "./gradlew"
+ - gradle_task: Ktx:assembleDebugAndroidTest
+ title: Build Test APK
+ - android-instrumented-test@0:
+ inputs:
+ - test_apk_path: "./Ext/Ktx/build/outputs/apk/androidTest/debug/testify-ktx-debug-androidTest.apk"
+ - main_apk_path: "./Ext/Ktx/build/outputs/apk/androidTest/debug/testify-ktx-debug-androidTest.apk"
+ - deploy-to-bitrise-io@2:
+ inputs:
+ - notify_user_groups: none
+ - github-status@3:
+ run_if: .IsCI
+ inputs:
+ - auth_token: $GITHUB_TOKEN
+ - status_identifier: "Ktx"
+ - pipeline_build_url: "$BITRISE_BUILD_URL"
+ before_run:
+ - _globalSetup
+ - _emulatorSetup
+
test_plugin:
steps:
- gradle-runner@2:
@@ -259,7 +302,7 @@ workflows:
- _globalSetup
meta:
bitrise.io:
- stack: ubuntu-noble-24.04-bitrise-2025-android
+ stack: linux-docker-android-22.04
machine_type_id: standard
test_compose_ext:
@@ -284,7 +327,7 @@ workflows:
- _globalSetup
meta:
bitrise.io:
- stack: ubuntu-noble-24.04-bitrise-2025-android
+ stack: linux-docker-android-22.04
machine_type_id: standard
test_fullscreen_ext:
@@ -309,7 +352,7 @@ workflows:
- _globalSetup
meta:
bitrise.io:
- stack: ubuntu-noble-24.04-bitrise-2025-android
+ stack: linux-docker-android-22.04
machine_type_id: standard
test_flix:
@@ -390,8 +433,8 @@ workflows:
meta:
bitrise.io:
- stack: ubuntu-noble-24.04-bitrise-2025-android
- machine_type_id: g2.linux.2medium
+ stack: linux-docker-android-22.04
+ machine_type_id: g2.linux.large
app:
envs:
- opts:
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b40c7e2a3..10adbf4b5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,7 +20,7 @@ kotlinxCoroutinesAndroid = "1.10.2"
ktlint = "13.1.0"
lifecycleRuntimeKtx = "2.9.3"
mapsplatformSecrets = "2.0.1"
-material = "1.12.0"
+material = "1.13.0"
materialVersion = "1.9.0"
mockk = "1.14.5"
mockkAndroid = "1.14.5"
diff --git a/settings.gradle b/settings.gradle
index 070a66306..e16acafe8 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -17,20 +17,22 @@ dependencyResolutionManagement {
}
}
-include ':LegacySample'
-include ':FlixSample'
+include ':Accessibility'
+include ':ComposeExtensions'
include ':FlixLibrary'
+include ':FlixSample'
+include ':FullscreenCaptureMethod'
include ':GmdSample'
-includeBuild("./Plugins/Gradle") { name = "Plugin" }
+include ':Ktx'
+include ':LegacySample'
include ':Library'
-include ':ComposeExtensions'
-include ':FullscreenCaptureMethod'
-include ':Accessibility'
+includeBuild("./Plugins/Gradle") { name = "Plugin" }
-project(':ComposeExtensions').projectDir = new File("./Ext/Compose")
-project(':FullscreenCaptureMethod').projectDir = new File("./Ext/Fullscreen")
project(':Accessibility').projectDir = new File("./Ext/Accessibility")
-project(':LegacySample').projectDir = new File("./Samples/Legacy")
-project(':FlixSample').projectDir = new File("./Samples/Flix")
+project(':ComposeExtensions').projectDir = new File("./Ext/Compose")
project(':FlixLibrary').projectDir = new File("./Samples/Flix/FlixLibrary")
+project(':FlixSample').projectDir = new File("./Samples/Flix")
+project(':FullscreenCaptureMethod').projectDir = new File("./Ext/Fullscreen")
project(':GmdSample').projectDir = new File("./Samples/Gmd")
+project(':Ktx').projectDir = new File("./Ext/Ktx")
+project(':LegacySample').projectDir = new File("./Samples/Legacy")