Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions android-snaptesting/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin)
}

android {
Expand Down Expand Up @@ -31,13 +30,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}

kotlin {
explicitApi()
jvmToolchain(17)
}

dependencies {
Expand Down
8 changes: 4 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin)
id("com.telefonica.androidsnaptesting-plugin")
}

Expand Down Expand Up @@ -31,9 +30,10 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}

kotlin {
jvmToolchain(17)
}

dependencies {
Expand Down
5 changes: 1 addition & 4 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Non-transitive R classes are the default in AGP 9.x and above
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[versions]
agp = "8.13.1"
agp = "9.1.0"
constraintlayout = "2.2.1"
min-sdk = "23"
target-sdk = "36"
compile-sdk = "36"
material = "1.12.0"
kotlin = "2.1.21"
kotlin = "2.3.0"
appcompat = "1.7.0"
androidx-junit = "1.2.1"
androidx-monitor = "1.7.2"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#Fri Mar 22 10:54:28 CET 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
package com.telefonica.androidsnaptesting

import com.android.build.gradle.TestedExtension
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.file.Directory
import org.gradle.api.provider.Provider
import org.gradle.api.provider.ProviderFactory
import java.io.File

class AndroidSnaptestingPlugin : Plugin<Project> {

override fun apply(project: Project) {
// Collect applicationId per test-variant name at configuration time using the new variant API.
// onVariants runs during project configuration, before afterEvaluate.
val applicationIds = mutableMapOf<String, Provider<String>>()

project.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)
?.onVariants { variant ->
// variant.name == "debug" β†’ test task variant name == "debugAndroidTest"
applicationIds["${variant.name}AndroidTest"] = variant.applicationId
}

project.afterEvaluate {

val deviceProviderInstrumentTestTasks = project.tasks
Expand All @@ -21,8 +33,8 @@ class AndroidSnaptestingPlugin : Plugin<Project> {
throw AndroidSnaptestingNoDeviceProviderInstrumentTestTasksException()
}

val extension = project.extensions.findByType(TestedExtension::class.java)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGP 9.x removed TestedExtension from the public API. We now use the stable
variant API (AndroidComponentsExtension / ApplicationAndroidComponentsExtension) which is the recommended way to interact with Android build variants since AGP 7.x.

?: throw RuntimeException("TestedExtension not found")
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java)
?: throw RuntimeException("AndroidComponentsExtension not found")

val isRecordMode = project.properties["android.testInstrumentationRunnerArguments.record"] == "true"
val providerFactory: ProviderFactory = project.providers
Expand All @@ -32,7 +44,18 @@ class AndroidSnaptestingPlugin : Plugin<Project> {
taskName,
DeviceProviderInstrumentTestTask::class.java,
).get()
registerTasksForVariant(project, taskName, deviceProviderTask, extension, isRecordMode, providerFactory)
val variantName = deviceProviderTask.variantName
val applicationIdProvider = applicationIds[variantName]
?: throw RuntimeException(
"applicationId not found for test variant '$variantName'. " +
"Available variants: ${applicationIds.keys}. " +
"Make sure the plugin is applied to a com.android.application module."
)
registerTasksForVariant(
project, taskName, deviceProviderTask,
androidComponents, applicationIdProvider,
isRecordMode, providerFactory,
)
}
}
}
Expand All @@ -42,17 +65,13 @@ class AndroidSnaptestingPlugin : Plugin<Project> {
project: Project,
taskName: String,
deviceProviderTask: DeviceProviderInstrumentTestTask,
extension: TestedExtension,
androidComponents: AndroidComponentsExtension<*, *, *>,
applicationIdProvider: Provider<String>,
isRecordMode: Boolean,
providerFactory: ProviderFactory,
) {
val capitalizedVariant = deviceProviderTask.variantName.capitalizeFirstLetter()

val testedVariant = extension.testVariants
.firstOrNull { it.name == deviceProviderTask.variantName }
?: throw RuntimeException("TestVariant not found for ${deviceProviderTask.variantName}")
val applicationIdProvider = providerFactory.provider { testedVariant.applicationId }
val adbExecutablePath = extension.adbExecutable.absolutePath
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adbExecutable was removed from TestedExtension in AGP 9.x.
sdkComponents.adb is the stable replacement: it returns a lazy Provider pointing to the adb binary resolved from the configured SDK.

val adbExecutablePath = androidComponents.sdkComponents.adb.get().asFile.absolutePath

val goldenSnapshotsSourcePath = run {
val variantSourceFolder = deviceProviderTask
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ package com.telefonica.androidsnaptesting

import com.android.build.gradle.internal.tasks.DeviceProviderInstrumentTestTask
import com.android.build.gradle.internal.testing.ConnectedDevice
import com.android.ddmlib.CollectingOutputReceiver
import com.android.ddmlib.FileListingService
import com.android.ddmlib.FileListingService.FileEntry
import com.android.ddmlib.IDevice
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ProviderFactory
import java.io.File
import java.util.concurrent.TimeUnit

fun DeviceProviderInstrumentTestTask.deviceFileManager(
applicationId: String,
Expand All @@ -23,39 +20,25 @@ class DeviceFileManager(
private val providerFactory: ProviderFactory,
) {

fun pullRecordedSnapshots(
destinationPath: String,
) {
fun pullRecordedSnapshots(destinationPath: String) {
pullSnapshots("recorded", destinationPath)
}

fun pullFailuresSnapshots(
destinationPath: String,
) {
fun pullFailuresSnapshots(destinationPath: String) {
pullSnapshots("failures", destinationPath)
}

fun clearAllSnapshots() {
withConnectedDevices { devices ->
devices.forEach {
val receiver = CollectingOutputReceiver()
it.iDevice.executeShellCommand("rm -rf ${getDeviceAndroidSnaptestingRootAbsolutePath()}", receiver)
println(receiver.output)
devices.forEach { device ->
runAdb(device.serialNumber, "shell", "rm", "-rf", getDeviceAndroidSnaptestingRootAbsolutePath())
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously used DDMLib's IDevice.executeShellCommand() to run 'rm -rf' on the device. Now delegates to runAdb(), which spawns an 'adb -s shell rm -rf ...' subprocess.

}
}
}

private fun String.toFileEntry(): FileEntry {
val parts = this.split("/")
var fileEntry = FileEntry(null, null, FileListingService.TYPE_DIRECTORY, true)
parts.forEach {
fileEntry = FileEntry(fileEntry, it, FileListingService.TYPE_DIRECTORY, false)
}
return fileEntry
}

private fun getDeviceAndroidSnaptestingRootAbsolutePath(): String =
"${FileListingService.DIRECTORY_SDCARD}/Download/android-snaptesting/$applicationId"
"/sdcard/Download/android-snaptesting/$applicationId"
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileListingService.DIRECTORY_SDCARD was a DDMLib constant ("/sdcard"). Using the literal string directly removes the DDMLib dependency entirely.


private fun getDeviceAndroidSnaptestingSubfolderAbsolutePath(subFolder: String): String =
"${getDeviceAndroidSnaptestingRootAbsolutePath()}/$subFolder"

Expand All @@ -77,25 +60,40 @@ class DeviceFileManager(
androidSnaptestingSubFolderInDevice: String,
destinationPath: String,
) {
val fileEntry = getDeviceAndroidSnaptestingSubfolderAbsolutePath(androidSnaptestingSubFolderInDevice).toFileEntry()
val remotePath = getDeviceAndroidSnaptestingSubfolderAbsolutePath(androidSnaptestingSubFolderInDevice)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old implementation built a FileEntry tree using DDMLib's FileListingService and then called IDevice.pullFile() for each entry.
The new implementation runs adb shell ls <remotePath> to list files and
adb pull <remote> <local> for each one, filtering out error lines from ls in case the folder doesn't exist yet on the device.

withConnectedDevices { devices ->
devices.forEach {
pullFolderFiles(
fileEntry,
it.iDevice,
destinationPath,
)
devices.forEach { device ->
val serial = device.serialNumber
// List files in the remote folder; ignore errors if the folder doesn't exist yet
val lsOutput = runAdbCapture(serial, "shell", "ls", remotePath)
val fileNames = lsOutput.lines()
.map { it.trim() }
.filter { it.isNotBlank() && !it.startsWith("ls:") && !it.contains("No such file") }
// Pull each file to the local destination
fileNames.forEach { fileName ->
runAdb(serial, "pull", "$remotePath/$fileName", "$destinationPath/$fileName")
}
}
}
}

private fun pullFolderFiles(
androidSnaptestingDeviceFolder: FileEntry,
device: IDevice,
destinationPath: String,
) {
device.fileListingService.getChildrenSync(androidSnaptestingDeviceFolder).forEach {
device.pullFile(it.fullPath, "$destinationPath/${it.name}")
private fun runAdb(serial: String, vararg args: String) {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convenience wrapper that additionally prints the output to the Gradle log. Both replace the DDMLib IDevice shell/pull APIs removed in AGP 9.x.

val output = runAdbCapture(serial, *args)
println(output)
}

private fun runAdbCapture(serial: String, vararg args: String): String {
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runAdbCapture spawns a subprocess with the adb binary, targeting a specific device by serial (-s flag), merges stderr into stdout, and waits up to 60 seconds.

val command = buildList {
add(adbExecutablePath)
add("-s")
add(serial)
addAll(args.toList())
}
val process = ProcessBuilder(command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor(60, TimeUnit.SECONDS)
return output
}
}
8 changes: 4 additions & 4 deletions include-build/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[versions]
agp = "8.4.1"
common = "31.4.1"
ddmlib = "31.4.1"
kotlin = "1.9.23"
agp = "9.1.0"
common = "32.1.0"
ddmlib = "32.1.0"
kotlin = "2.3.0"
detekt = "1.23.6"
publish-plugin = "1.2.0"

Expand Down
2 changes: 1 addition & 1 deletion mavencentral.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ publishing {
artifactId 'androidsnaptesting'
version version

artifact("$buildDir/outputs/aar/android-snaptesting-release.aar")
artifact("${layout.buildDirectory.get().asFile}/outputs/aar/android-snaptesting-release.aar")
artifact androidSourcesJar

pom {
Expand Down
Loading