Slide 1

Slide 1 text

Bring your own phones to Gradle Managed Devices Yury Vlad linkedin.com/in/yury-vlad DroidKaigi 2026 1

Slide 2

Slide 2 text

Agenda • What Gradle Managed Devices are • How to build your own Managed Devices step by step • How instrumented tests work under the hood • How to benefit from a company device farm DroidKaigi 2026 2

Slide 3

Slide 3 text

Notice APIs from these slides are @Incubating and subject to change. • Gradle: 9.6.1 • Android Gradle Plugin: 9.3.1 DroidKaigi 2026 3

Slide 4

Slide 4 text

The problem – manual emulator management Running UI tests required these steps. 1. Manually start an emulator 2. Wait for it to boot 3. Run connectedAndroidTest 4. Stop the emulator DroidKaigi 2026 4

Slide 5

Slide 5 text

Gradle Managed Devices android { testOptions { managedDevices { devices { register("pixel2api30", ManagedVirtualDevice) { device = "Pixel 2" apiLevel = 30 systemImageSource = "aosp" } } } } } ./gradlew pixel2api30Check : AGP creates, boots, runs tests, and stops the emulator. DroidKaigi 2026 5

Slide 6

Slide 6 text

Why use Gradle Managed Devices? • No manual emulator management • Same environment on every machine • Snapshots for faster cold starts • Works on CI as is DroidKaigi 2026 6

Slide 7

Slide 7 text

Firebase Test Lab for Gradle Managed Devices plugins { id("com.google.firebase.testlab") } firebaseTestLab { managedDevices { register("ftlDevice") { device = "Pixel3" apiLevel = 30 } } } Similar DSL, different execution environment. DroidKaigi 2026 7

Slide 8

Slide 8 text

How does Firebase Test Lab plug in? Both ManagedVirtualDevice and Firebase's ManagedDevice implement this interface. com.android.build.api.dsl.Device DroidKaigi 2026 8

Slide 9

Slide 9 text

First Goal Connect to local emulator and run tests DroidKaigi 2026 9

Slide 10

Slide 10 text

Gradle basics What you need to know for this talk. • A Gradle Plugin is reusable build logic • Marking task inputs/outputs • Gradle has own DI • In buildSrc or an included build DroidKaigi 2026 10

Slide 11

Slide 11 text

Register a custom device type This is the entry point for a custom Managed Device. managedDeviceRegistry.registerDeviceType(MyDevice::class.java) { dslImplementationClass = MyDeviceImpl::class.java setSetupActions(...) setTestRunActions(...) } DroidKaigi 2026 11

Slide 12

Slide 12 text

MyDevice A custom device that connects to an already running emulator. interface MyDevice : Device { @get:Input val host: Property @get:Input val port: Property } internal abstract class MyDeviceImpl : MyDevice { init { host.convention("localhost") port.convention(5555) } } DroidKaigi 2026 12

Slide 13

Slide 13 text

Gradle decorators Gradle creates a generated subclass at runtime ("decoration"). • Implement abstract properties (e.g. Property ) • Inject dependencies into constructors and fields (like Dagger) • Track changes for up-to-date checks (e.g. @get:Input ) Uses tocjava/asm to generate decorator bytecode at runtime. DroidKaigi 2026 13

Slide 14

Slide 14 text

Setup step — structure fun setSetupActions( configureAction: Class>, taskAction: Class>, ) • SetupInputT is derived from Device instance • DeviceSetupTaskAction accepts SetupInputT and runs and in each module • Output: files that the test-run step can read later DroidKaigi 2026 14

Slide 15

Slide 15 text

Setup step — input Input data for the setup task. abstract class MyDeviceSetupInput : DeviceSetupInput { @get:Nested abstract val device: Property } DroidKaigi 2026 15

Slide 16

Slide 16 text

Setup step — configure action abstract class MyDeviceSetupConfigureAction : DeviceSetupConfigureAction { @get:Inject abstract val objectFactory: ObjectFactory } override fun configureTaskInput(deviceDSL: MyDevice): MyDeviceSetupInput { return objectFactory.newInstance(MyDeviceSetupInput::class.java).apply { device.set(deviceDSL) } } DroidKaigi 2026 16

Slide 17

Slide 17 text

Setup step — task action abstract class MyDeviceSetupTaskAction : DeviceSetupTaskAction { override fun setup(setupInput: MyDeviceSetupInput, outputDir: Directory) { val device = setupInput.device.get() ... outputDir.file("test").asFile.writeText("hello") } } MyDeviceSetupInput → MyDeviceSetupTask → File(s) → MyDeviceDeviceTestRunTask DroidKaigi 2026 17

Slide 18

Slide 18 text

How ManagedVirtualDevice uses setup Google's implementation ( ManagedVirtualDevice ). • Download emulator images via sdkmanager • Create emulator snapshot with qemu ( AvdComponentsBuildService ) DroidKaigi 2026 18

Slide 19

Slide 19 text

Setup step — custom emulator Share emulator lifecycle across tasks with a Gradle BuildService . abstract class CustomEmulatorBuildService : BuildService { suspend fun prepareEmulatorImage() = mutex.withLock { downloadImage() createSnapshot() } } suspend fun executeWithEmulator(action: () -> Unit) { val qemuProcess = launchEmulator() try { action() } finally { stopEmulator(qemuProcess) } } DroidKaigi 2026 19

Slide 20

Slide 20 text

Registering the BuildService Register the service once when the plugin is applied. class MyDevicePlugin : Plugin { override fun apply(target: Project) { target.gradle.sharedServices .registerIfAbsent( "custom-emulator", CustomEmulatorBuildService::class.java ) { paramsForSetup -> ... } } } DroidKaigi 2026 20

Slide 21

Slide 21 text

Setup step — inject BuildService Inject the shared service into the setup task. abstract class MyDeviceSetupTaskAction : DeviceSetupTaskAction { // The name must match the name used in registerIfAbsent @get:ServiceReference("custom-emulator") abstract val service: Property } override fun setup(setupInput: MyDeviceSetupInput, outputDir: Directory) = runBlocking { val device = setupInput.device.get() ... service.get().prepareEmulatorImage() } DroidKaigi 2026 21

Slide 22

Slide 22 text

Setup step — AGP references Internal AGP APIs: • tasks.ManagedDeviceInstrumentationTestSetupTask • AvdComponentsBuildService DroidKaigi 2026 22

Slide 23

Slide 23 text

Registration managedDeviceRegistry.registerDeviceType(MyDevice::class.java) { dslImplementationClass = MyDeviceImpl::class.java setSetupActions( MyDeviceSetupConfigureAction::class.java, MyDeviceSetupTaskAction::class.java, ) // TODO setTestRunActions(...) } DroidKaigi 2026 23

Slide 24

Slide 24 text

Run step — API fun setTestRunActions( configureAction: Class>, taskAction: Class>, ) • DeviceTestRunInput : data model for the test run, from the Device interface • DeviceTestRunTaskAction : runs the tests, with compiled APKs and test parameters DroidKaigi 2026 24

Slide 25

Slide 25 text

Run step — input // Option 1: Pass the entire device object abstract class MyDeviceTestRunInput : DeviceTestRunInput { @get:Nested abstract val device: Property } @get:Input abstract val sampleProperty: Property DroidKaigi 2026 25

Slide 26

Slide 26 text

Run step — task action abstract class MyDeviceDeviceTestRunTaskAction : DeviceTestRunTaskAction { } override fun runTests( params: DeviceTestRunParameters ): Boolean { TODO("Implementation") // Return true if tests passed and false if failed return true } DroidKaigi 2026 26

Slide 27

Slide 27 text

DeviceTestRunParameters interface DeviceTestRunParameters { // Configuration for this specific test run val deviceInput: MyDeviceTestRunInput // Directory containing files produced during the setup step val setupResult: DirectoryProperty } // Compiled APKs and essential test-run metadata val testRunData: TestRunData DroidKaigi 2026 27

Slide 28

Slide 28 text

How do instrumented tests actually run? • Run button in Android Studio • ./gradlew app:connectedAndroidTest • ??? DroidKaigi 2026 28

Slide 29

Slide 29 text

How instrumented tests work • Install target APK • Install test APK • Execute adb shell am instrument -w / • Handle the results DroidKaigi 2026 29

Slide 30

Slide 30 text

ADB To communicate with the device, we have three main options. • adb CLI: the standard command-line tool • ddmlib : AndroidDebugBridge (the same one AGP uses) • dadb : a pure Kotlin/JVM implementation of the protocol (by mobile.dev) DroidKaigi 2026 30

Slide 31

Slide 31 text

Running test — dadb Dadb.create(host, port).use { dadb -> // 1. Install the APK dadb.install(apkFile) // 2. Execute the instrumentation command val outputs = dadb.shell("am instrument ...") } // 3. Return success or failure based on output return outputs.isSuccess DroidKaigi 2026 31

Slide 32

Slide 32 text

Installing APKs testRunData.testData.testedApkFinder:(DeviceConfigProvider) -> List • Returns APKs matching device ABI, density, and language • Required to handle Split APKs • Returns an empty list for library modules testRunData.testData.testApk: File • The APK containing the test code • For library modules, one APK with test and target code DroidKaigi 2026 32

Slide 33

Slide 33 text

DeviceConfigProvider — API public interface DeviceConfigProvider { @NonNull String getConfigFor(String abi); int getDensity(); @NonNull List getAbis(); } ... DroidKaigi 2026 33

Slide 34

Slide 34 text

DeviceConfigProvider — implementation override fun getLanguage(): String = dadb .shell("getprop ${IDevice.PROP_DEVICE_LANGUAGE}") .output private val config by lazy { val result = dadb.shell("am get-config") DeviceConfig.Builder.parse(result.output.split("\n")) } override fun getConfigFor(abi: String): String = config.getConfigFor(abi) DroidKaigi 2026 34

Slide 35

Slide 35 text

Install target APK val apks = params.testRunData.testData.testedApkFinder .invoke(DadbDeviceConfigProvider(dadb)) if (apks.isNotEmpty()) { // Empty in case of library module dadb.installMultiple( apks = apks, options = params.testRunData.additionalInstallOptions, ) } dadb.install( file = testData.testApk, options = additionalInstallOptions.toTypedArray(), ) DroidKaigi 2026 35

Slide 36

Slide 36 text

Running test — am instrument val output = dadb // "testData" provides more parameters which should be used, // but it is minimal working setup. .shell("am instrument -w ${testData.applicationId}/${testData.instrumentationRunner}") .output // `am instrument -w` prints a JUnit-style summary: // `OK (N tests)` on full success, // `FAILURES!!!` on test failures, // neither on a crash / failed-to-start. // Treat anything but OK as failure. Regex("""(?m)^OK \(\d+ test""").containsMatchIn(output) DroidKaigi 2026 36

Slide 37

Slide 37 text

Minimal setup is ready ./gradlew app:myDeviceCheck 37

Slide 38

Slide 38 text

Tests run — empty report > Task :lib:myDeviceDebugAndroidTest > Task :lib:mergeDebugAndroidTestTestResultProtos Test execution completed. See the report at: file:///.../managedDevice/debug/allDevices/index.html DroidKaigi 2026 38

Slide 39

Slide 39 text

How are results parsed? adb shell am instrument -w returns a continuous stream of data. • Test names • Test statuses (passed, failed, skipped) • Result metadata How does AGP turn this stream into a JUnit report? DroidKaigi 2026 39

Slide 40

Slide 40 text

Parsing test results — listener val xmlWriterListener = CustomTestRunListener( name, projectPath, variantName, LoggerWrapper(logger), ) xmlWriterListener.setReportDir(outputDirectory) xmlWriterListener.setHostName("$host:$port") // We must use PROTO_STD to match what AGP expects val mode = RemoteAndroidTestRunner.StatusReporterMode.PROTO_STD val parser = mode.createInstrumentationResultParser(runId, listOf(xmlWriterListener)) DroidKaigi 2026 40

Slide 41

Slide 41 text

Parsing test results — stream val mode = RemoteAndroidTestRunner.StatusReporterMode.PROTO_STD dadb.openShell("am instrument -w ${mode.amInstrumentCommandArg} ...") .use { stream -> while (true) { val packet: AdbShellPacket = stream.read() if (packet is AdbShellPacket.Exit) break parser.addOutput(packet.payload, 0, packet.payload.size) } parser.flush() } return !xmlWriterListener.runResult.hasFailedTests() DroidKaigi 2026 41

Slide 42

Slide 42 text

First Goal — done > Task :lib:myDeviceDebugAndroidTest Finished 10 tests on emulator-5554 > Task :lib:mergeDebugAndroidTestTestResultProtos Test execution completed. See the report at: file:///.../managedDevice/debug/allDevices/index.html DroidKaigi 2026 42

Slide 43

Slide 43 text

Second Goal Connect to remote emulator and run tests DroidKaigi 2026 43

Slide 44

Slide 44 text

Remote execution Dadb.create connects via TCP, so we can target any IP address. interface MyDevice : Device { @get:Input val host: Property @get:Input val port: Property } register("remoteDevice", MyDevice) { host = "192.168.1.42" port = 5555 } Dadb.create(device.host.get(), device.port.get()) DroidKaigi 2026 44

Slide 45

Slide 45 text

You could already do this adb connect 192.168.1.42:5555 adb devices > List of devices attached > 192.168.1.42:5555 device Why use Gradle Managed Devices for this? • No manual adb connect step • Connection details live in the build script • Easy to assign a specific device per CI job or module • Unified Gradle workflow instead of external shell scripts DroidKaigi 2026 45

Slide 46

Slide 46 text

Third Goal Run tests in parallel on multiple devices DroidKaigi 2026 46

Slide 47

Slide 47 text

Parallel execution — the problem Running 100 UI tests on a single device takes ~1m 51s. Test sharding: split the tests across N devices and run them at the same time DroidKaigi 2026 47

Slide 48

Slide 48 text

AndroidJUnitRunner sharding # Run even index tests adb shell am instrument -w -e numShards 2 -e shardIndex 0 / # Run odd index tests adb shell am instrument -w -e numShards 2 -e shardIndex 1 / Gradle Managed Devices has android.experimental.androidTest.numManagedDeviceShards=N but only for ManagedVirtualDevice . DroidKaigi 2026 48

Slide 49

Slide 49 text

MultipleDevices — a new device type Device can stand for many real devices as one logical unit. interface MultipleDevices : Device { @get:Input val hosts: ListProperty } register("multipleDevices", MultipleDevices) { hosts.addAll( "192.168.1.43:5555", "192.168.1.42:5557", ... ) } DroidKaigi 2026 49

Slide 50

Slide 50 text

MultipleDevices — parallel runTests override fun runTests(...): Boolean = runBlocking { val devices = device.devices.get() devices .map { it.splitIntoHostAndPort() } .mapIndexed { index, (host, port) -> // OK to use for IO-bound. // For CPU-bound tasks: Gradle Worker API async(Dispatchers.IO) { runShardedTest(index, devices.size(), host, port, ...) } } .awaitAll() .all { it } } DroidKaigi 2026 50

Slide 51

Slide 51 text

MultipleDevices — results > Task :lib:mergeDebugAndroidTestTestResultProtos Test execution completed. See the report at: file:///.../managedDevice/debug/allDevices/index.html DroidKaigi 2026 51

Slide 52

Slide 52 text

MultipleDevices — sharding disbalance package androidx.test.runner; private static class ShardingFilter { @Override public boolean shouldRun(Description description) { // Distribution based on hash code if (description.isTest()) { return (Math.abs(description.hashCode()) % numShards) == shardIndex; } return true; } } DroidKaigi 2026 52

Slide 53

Slide 53 text

Sharding performance results 100 UI tests Emulators Test time Speedup 1 1m 51s 1× 2 1m 1.9× 3 45s 2.5× DroidKaigi 2026 53

Slide 54

Slide 54 text

The remaining problem Sharding increases speed, but emulators still run locally. Running multiple emulators at the same time leads to. • High resource consumption (CPU and RAM) • Performance degradation on developer machines • Resource contention on CI — build tasks and emulators competing for the same CPU DroidKaigi 2026 54

Slide 55

Slide 55 text

Fourth Goal Running tests on a device farm DroidKaigi 2026 55

Slide 56

Slide 56 text

Device farm A shared pool of resources. • Physical hardware: USB racks in the office • Remote emulators: Hosted in a data center • Shared access: Used by developers and CI • Exclusive use: One client per device DroidKaigi 2026 56

Slide 57

Slide 57 text

Device broker A web service in front of the farm. • List free devices • Lease a device — only you can use it • Return host and port for ADB • Release the device after the test DroidKaigi 2026 57

Slide 58

Slide 58 text

DeviceFarmer STF DeviceFarmer/stf Open-source device farm solution. • Web UI: Monitor, occupy, and control devices • REST API: Automate device lifecycle • Remote ADB: Dedicated URL per device DroidKaigi 2026 58

Slide 59

Slide 59 text

DeviceFarmer STF — devices DroidKaigi 2026 59

Slide 60

Slide 60 text

DeviceFarmer STF — remote control DroidKaigi 2026 60

Slide 61

Slide 61 text

REST API workflow • Discovery: GET /devices and filter by serial , present , ready , using , owner • Lease: POST /user/devices leases a device by serial • Connect: POST /user/devices/{serial}/remoteConnect returns remoteConnectUrl • Disconnect: DELETE /user/devices/{serial}/remoteConnect closes the session • Release: DELETE /user/devices/{serial} releases the device DroidKaigi 2026 61

Slide 62

Slide 62 text

StfDevice interface StfDevice : Device { @get:Input val maxParallelization: Property } abstract class StfDeviceImpl : StfDevice { init { maxParallelization.convention(1) } } register("stfDevice", StfDevice) { maxParallelization.set(4) } DroidKaigi 2026 62

Slide 63

Slide 63 text

StfDevice — test-run input abstract class StfDeviceTestRunInput : DeviceTestRunInput { @get:Nested abstract val device: Property @get:Input abstract val stfUrl: Provider @get:Input abstract val stfToken: Provider } abstract class StfDeviceTestRunConfigureAction @Inject constructor( private val providers: ProviderFactory, ... ) : DeviceTestRunConfigureAction { override fun configureTaskInput(deviceDSL: StfDevice): StfDeviceTestRunInput { ... } } DroidKaigi 2026 // Use providers to pull global configuration stfUrl.set(providers.gradleProperty("stf.url")) stfToken.set(providers.gradleProperty("stf.token")) 63

Slide 64

Slide 64 text

Gradle properties factory.gradleProperty("stf.token") Resolution order: • ./gradlew task -P stays in bash history • ORG_GRADLE_PROJECT_* environment — safe • ~/.gradle/gradle.properties — safe • Project gradle.properties — goes to Git DroidKaigi 2026 64

Slide 65

Slide 65 text

StfDevice — runTests override fun runTests(...): Boolean = runBlocking { ... val deviceProvider = StfDeviceProvider( baseUrl = params.deviceInput.stfUrl.get(), token = params.deviceInput.stfToken.get(), ) return deviceProvider .withAdbDevices(maxParallelism = device.maxParallelization.get()) { adbUrls -> // Run sharded tests in parallel } } DroidKaigi 2026 65

Slide 66

Slide 66 text

StfDeviceProvider.withAdbDevices fun withAdbDevices( maxParallelism: Int, action: (List) -> T, ): T { val serials = listAvailable(limit = maxParallelism) try { urls = serials.mapNotNull { serial -> reserve(serial, 5.min) remoteConnect(serial) } if (urls.isEmpty()) retry() return action(urls) } finally { urls.reversed().forEach { disconnect(); release() } } } DroidKaigi 2026 66

Slide 67

Slide 67 text

Demo 67

Slide 68

Slide 68 text

STF — ADB key auth issue Issues: • adb connect returns auth failed but shows status as connected • scrcpy fails to connect • STF proxy does not announce supported features • STF proxy fails public key signature checks Workaround: # Docker FROM devicefarmer/stf:latest COPY patches/connect.js /app/lib/units/device/plugins/connect.js DroidKaigi 2026 68

Slide 69

Slide 69 text

ADB key distribution • Emulators: Accept any ADB key • Physical devices: Require manual confirmation Solution: • Distribute a shared ADB key-pair • Use Dadb.create(..., keyPair = AdbKeyPair(...)) DroidKaigi 2026 69

Slide 70

Slide 70 text

Network security • Do not expose STF or devices to a public IP • Do not use adb connect over public networks • Always use a VPN DroidKaigi 2026 70

Slide 71

Slide 71 text

Redroid redroid/redroid • Ubuntu 22.04: highly recommended • macOS Docker: incompatible, needs Linux kernel extensions DroidKaigi 2026 71

Slide 72

Slide 72 text

Docker x-redroid: &redroid image: redroid/redroid:16.0.0_64only-latest services: redroid1: <<: *redroid adb: dockerfile: Dockerfile.adb rethinkdb: stf: dockerfile: Dockerfile.stf command: stf local --allow-remote --adb-host adb DroidKaigi 2026 72

Slide 73

Slide 73 text

Results • Local devices: Connect, install, test, and report • Parallel execution: Shard tests across devices • Device farms: Lease, run, and release devices • Custom emulators: Extend the same flow with setup DroidKaigi 2026 73

Slide 74

Slide 74 text

Is it useful? • Custom emulators: Unify emulator lifecycle in the Gradle build script • Test sharding: Simpler via the Device API • CI: Move emulators off the CI agent • Phone rack: Use shared hardware without manual ADB • DroidKaigi 2026 Risk: A failed Gradle process can leave a device leased 74

Slide 75

Slide 75 text

What we learned • Device is a public extensibility point • Instrumented tests are: install → instrument → parse → report • One Device can represent one device, many devices, or a farm DroidKaigi 2026 75

Slide 76

Slide 76 text

Thanks! Presentation on speakerdeck.com DroidKaigi 2026 76