Reservations, permissions, flaky-test traps, and Kotlin you can paste into your own test suite.
Every Android team eventually meets the bug that only exists on one phone. It’s a vendor-specific keyboard quirk, a camera pipeline that behaves differently on one chipset, or a foldable posture that your emulator image never produced. You can’t buy every device, and the emulator, great as it is, is still a simulation.
Android Device Streaming, which Google runs on top of Firebase, closes that gap by letting Android Studio reserve a physical phone in a remote lab and talk to it over an ordinary ADB connection. This post is not a click-through of the setup screens, which the official page already covers. It’s about what changes in your engineering habits once the device is not on your desk: permissions, session discipline, test design, and the failure modes that show up only on the second or third day of using it.
🧠 TL;DR (Too Long; Didn’t Read)
- Treat a streamed device as a leased, wiped-on-return resource, not as “a phone that happens to be far away.”
- Most pain comes from four places: IAM permissions, forgotten reservations, tests that assume local timing, and tests that assume surviving state.
- Make your instrumentation tests remote-aware with scaled timeouts, polling instead of sleeping, and an explicit backend preflight.
- Use streaming for model-specific investigation. Use scheduled device farms for sweeping regression runs.
The mental model: two planes
The easiest way to reason about the service is to separate who may hold a device from how your tools talk to it.
The control plane is your Firebase project. It decides whether you are allowed to reserve anything and tracks the minutes you consume. The data plane is a direct ADB-over-SSL link between your machine and the lab device.
That second part has a useful consequence: once the reservation exists and the device is connected, anything in your toolchain that speaks ADB can use it. Studio is needed to request the device, but Gradle, scripts, and third-party runners treat it like any other attached unit.
Permissions: the first wall you will hit
The access rule is simple and routinely forgotten: the Firebase project you pick needs you to be an Editor or Owner. A teammate on a locked-down corporate project will see an error that looks like a product bug but is really an IAM gap.
The fix is to have someone with authority on the project grant the right roles in Google Cloud IAM. The documentation names two: the Firebase Test Lab direct-access admin role and the Service Usage Consumer role. If your organization manages IAM as code, add both to the same group definition so new hires are productive on day one.
Two more permission-adjacent surprises are worth knowing:
- Fresh projects lag. A brand-new Firebase project can take a short while to show up in the picker. Retrying beats recreating.
- Partner labs are opt-in per project. Devices from OEM-operated labs sit in the same catalog, but an Editor or Owner has to enable each partner lab in the Cloud Console before you can use them. Studio will prompt you, but only someone with the right role can actually say yes.
A final gate is the IDE version. The service is tied to recent Android Studio releases, and older installs are asked to update. The page itself words this requirement in more than one way, so check the current compatibility note before you roll it out to a large team.
The session lifecycle, and where it bites
A reservation is a lease, and leases have terms. Three habits prevent most of the trouble:
Extend before you need to. The Running Devices toolbar lets you add time. Long debugging sessions and long test suites both tend to fail at the least convenient moment if you rely on the original window.
Return the device explicitly. Closing a tab is not the same as handing the phone back. Use Return and Erase Device, either from the device’s overflow menu in Device Manager or from the prompt that appears when you close the device tab. Unused minutes flow back to your project, and the device is wiped for the next person.
Assume nothing survives. Because the unit is factory reset on return, anything you configured by hand, such as a test account, a debug flag, or a sideloaded fixture, is gone by your next session. That one fact drives most of the test-design advice below.
Pick the cheapest target that can still tell the truth
Streaming is not a replacement for everything else. It’s a specific tool with a specific sweet spot.
My rule of thumb: start on an emulator for the inner loop, escalate to a streamed device when the bug smells like hardware or vendor software, and push breadth-oriented regression work to a scheduled farm. The no-cost allowance matters here: at the time of writing, Firebase’s pricing page lists 30 no-cost minutes per project, per month for Android Device Streaming on the Spark plan. That covers a few focused debugging sessions and nothing like a sustained automation workload, because the quota is per project, not per developer. Usage beyond it can be billed, and I haven’t confirmed a per-minute streaming rate, so check the current Test Lab pricing page before you plan a budget around it.
Writing tests that know they are remote
An instrumentation suite tuned on a phone plugged into your laptop will contain hidden assumptions: animations settle in a few hundred milliseconds, the network is the same one your emulator uses, and the app always starts in a state you left behind. Remote devices challenge all three.
1. Tell the tests where they are
Pass a flag from Gradle and read it through the instrumentation arguments. No device sniffing, no guessing.
./gradlew connectedDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.deviceTarget=remote import androidx.test.platform.app.InstrumentationRegistry import kotlin.time.Duration object TestEnv { private val args = InstrumentationRegistry.getArguments() val isRemote: Boolean = args.getString("deviceTarget") == "remote" /** Multiplier applied to every wait budget in the suite. */ val timeoutScale: Double = args.getString("timeoutScale")?.toDoubleOrNull() ?: if (isRemote) 2.5 else 1.0 fun scaled(base: Duration): Duration = base * timeoutScale } Duration * Double relies on the stable times(Double) operator, available since Kotlin 1.6. If you're stuck on an older toolchain, use (base.inWholeMilliseconds * timeoutScale).toLong().milliseconds instead. A single multiplier is deliberately crude. It gives you one dial to turn when the lab is slow today, instead of a hundred scattered constants.
2. Poll, don’t sleep
Thread.sleep(2000) is a local-latency guess frozen into code. Replace it with a bounded poll that backs off and fails with a message a human can act on.
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds suspend fun <T : Any> awaitNotNull( what: String, timeout: Duration = 5.seconds, pollEvery: Duration = 100.milliseconds, probe: () -> T?, ): T { val budget = TestEnv.scaled(timeout) return try { withTimeout(budget) { var wait = pollEvery var result = probe() while (result == null) { delay(wait) wait = minOf(wait * 1.5, 500.milliseconds) result = probe() } result } } catch (e: TimeoutCancellationException) { throw AssertionError("Gave up after $budget waiting for: $what", e) } } Failure mode worth naming: call this from runBlocking, not from runTest. The coroutine test dispatcher fast-forwards virtual time, so your "five seconds" of waiting would elapse instantly while the real device is still rendering. That produces tests that fail only on hardware, which is the most confusing flavor of flaky.
3. Fail fast when the backend is unreachable
Your remote device reaches the internet from a lab network, not from your office or VPN. If your staging API sits behind an IP allowlist, every test touching it will fail in ways that look like app bugs. A one-time preflight turns that into a single, clear error.
import okhttp3.OkHttpClient import okhttp3.Request import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit class BackendPreflightRule(private val healthUrl: String) : TestRule { override fun apply(base: Statement, description: Description) = object : Statement() { override fun evaluate() { cache.getOrPut(healthUrl) { runCatching { probe(healthUrl) } } .getOrThrow() base.evaluate() } } private companion object { val cache = ConcurrentHashMap<String, Result<Unit>>() val client = OkHttpClient.Builder() .callTimeout(10, TimeUnit.SECONDS) .build() fun probe(url: String) { client.newCall(Request.Builder().url(url).build()).execute().use { r -> check(r.isSuccessful) { "Backend health check $url returned HTTP ${r.code}. " + "If this only happens on a remote device, suspect network " + "allowlisting rather than the app." } } } } } The cache matters: the probe runs once per process, not once per test, so you pay ten seconds at most.
4. Never rely on leftover state
A wiped device means every session starts from “first launch.” Make that the norm for tests too by running them under the AndroidX Test Orchestrator and clearing package data between tests.
// build.gradle.kts (module) android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["clearPackageData"] = "true" } testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } } dependencies { androidTestUtil("androidx.test:orchestrator:<latest-stable>") } Seed whatever the screen under test needs in @Before, through your own debug-only hooks, rather than depending on a manual login you performed once.
5. Exercise rotation, because you finally can
One of the best reasons to use real hardware is physical behavior: rotation, and on foldables, folding. Here’s a rotation round-trip that checks that user input survives a configuration change:
import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.closeSoftKeyboard import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class CheckoutRotationTest { private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @After fun restoreOrientation() { device.unfreezeRotation() device.setOrientationNatural() } @Test fun deliveryNoteSurvivesRotation() = runBlocking { ActivityScenario.launch(CheckoutActivity::class.java).use { onView(withId(R.id.deliveryNote)) .perform(typeText("Leave at the side door"), closeSoftKeyboard()) device.setOrientationLeft() awaitNotNull("note restored after rotation") { runCatching { onView(withId(R.id.deliveryNote)) .check(matches(withText("Leave at the side door"))) }.getOrNull() } } } } CheckoutActivity and the view IDs are placeholders for your own screens. Espresso already synchronizes with the main thread and any registered IdlingResource, so use awaitNotNull only for conditions Espresso cannot see, such as slow rendering after a rotation on a remote device. If the delay comes from your own async work, such as a repository fetch, expose it through an IdlingResource instead of polling around it. The important detail is the @After: a remote device keeps its orientation lock until something releases it, and a test that forgets to restore it will poison every test after it in the same session.
6. Gate hardware-dependent tests honestly
A shared lab unit is not your personal phone. It may lack a SIM, a configured fingerprint, or a signed-in account. Skip tests that need those, and say why:
import android.content.pm.PackageManager import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assume.assumeTrue fun requireFeature(feature: String) { val pm = InstrumentationRegistry.getInstrumentation().targetContext.packageManager assumeTrue("Device lacks $feature", pm.hasSystemFeature(feature)) } // usage: requireFeature(PackageManager.FEATURE_TELEPHONY) Keep in mind that hasSystemFeature reports whether the hardware capability exists, not whether a SIM is inserted. If the scenario needs an active line, also check TelephonyManager.simState, which describes the default slot. On dual-SIM hardware, use getSimState(slotIndex) to ask about a specific slot. Telephony APIs are permission-sensitive and their requirements have shifted across Android versions and vendors, so if a SIM check throws a SecurityException or returns an unknown state on a lab device, grant READ_PHONE_STATE in test setup with GrantPermissionRule.grant(Manifest.permission.READ_PHONE_STATE) and re-check the current TelephonyManager reference for your target API level.
Scripts that survive a changing device
Every reservation hands you a fresh connection, so a serial number copied into a script last week will point at nothing today. Resolve it at run time and hand it to Gradle through the standard environment variable:
SERIAL=$(adb devices | awk 'NR>1 && $2=="device" {print $1}' | grep -v '^emulator-' | head -n1) [ -n "$SERIAL" ] || { echo "No physical device attached; reserve one first." >&2; exit 1; } ANDROID_SERIAL="$SERIAL" ./gradlew connectedDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.deviceTarget=remote If you keep a physical phone on your desk as well, this heuristic can pick the wrong one. In that case, filter by model through adb devices -l rather than taking the first match.
For people who live in the terminal, Google also ships an Android CLI whose android device remote command reserves and connects to remote physical devices without opening the IDE. It's worth a try if you want the same capability in scripted workflows.
The failure-mode cheat sheet
Pin this near your team’s CI documentation. Notice that none of the rows are about the service being unreliable. They’re all about assumptions that were harmless locally and become expensive remotely.
A short operating agreement for teams
- One owner per Firebase project’s quota. Shared minutes disappear quickly when five people each leave a session open.
- Document the IAM roles in your onboarding guide so nobody spends their first morning on a permissions error.
- Return and Erase is part of “done.” Add it to your pull-request checklist for device-specific bug fixes if you have to.
- Record the device model in every bug report you file from a streamed session, so the next person can reproduce it on the same hardware.
- Re-check pricing and device availability periodically. The catalog and the free allowance are both things that change over time.
🔚 Closing thoughts
Remote real hardware is one of the cheapest ways to turn “can’t reproduce” into “reproduced, with a stack trace.” But it rewards teams that treat a device as a leased, disposable, slightly distant resource. If your tests poll instead of sleep, seed their own state, verify their network assumptions up front, and release what they borrow, the service mostly fades into the background, which is exactly where infrastructure should sit.
📱 Stop Guessing Whether Your Android Code Actually Works
If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend “Mastering Android Testing” — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.




