A screen can pass every rotation test you have and still crash the first time Android reclaims the app in the background. Here is one that does:

object Session { lateinit var user: String } class SessionActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val user = Session.user // ... } } 

MainActivity sets Session.user before it opens this screen. Rotate the phone and nothing goes wrong, because the process and the singleton are still alive. Kill the process while the app is in the background and Android recreates SessionActivity directly. MainActivity never runs, and Session.user throws.

The quieter version of this bug doesn't crash. A ViewModel keeps a half-written form in memory, rotation keeps the ViewModel, and process death takes the form with it.

I built process-death-check to catch both. Most of the work went into a question I had underestimated: how do you know your test really tested this?

What the usual checks do

  • Rotation and ActivityScenario.recreate() recreate the Activity. The process survives, so ViewModels and singletons keep their data.
  • "Don't keep activities" destroys Activities and leaves the process running.
  • am force-stop goes too far. Android treats it as the user stopping the app and drops the back stack, so you get a cold start.
  • am kill, or a scripted kill -9, is a real kill. It only tests restoration if it happens at the right moment.

Timing is the hard part

Press Home and kill the process straight away, and the test can still pass. It just tested something else. An Activity's saved state reaches the system around the time the Activity stops, a little after Home is pressed. Kill the process before that and the system has nothing to restore, so you end up with something much closer to a cold start.

You don't have to guess or sleep. The system reports it in dumpsys activity activities:

Intent { flg=0x10000000 xflg=0x4 cmp=dev.byfedin.pdcheck.sample/.SavedFormActivity } mHaveState=true mIcicle=Bundle[mParcelledData.dataSize=1940] state=STOPPED delayedResume=false finishing=false 

So the check polls until every Activity of the task shows STOPPED with saved state, and only then kills. The field is mHaveState on API 36 and haveState on API 26, so the parser accepts both.

Real runs added three rules I hadn't planned for:

  • Old tasks from recents appear in the same dump, and they can hold the wait forever. The wait has to look only at the task under test.
  • An Activity lower in the stack may never have been started. It sits in INITIALIZING with no state, and that's fine.
  • After an earlier process death, a record can be DESTROYED and still have saved state. That's fine too.

Coming back the way a user does

My first version started the app again with a plain launch and landed on the wrong screen. To return to the retained task, you need the task's root Intent with the flags a launcher uses, FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED:

adb shell am start \ -a android.intent.action.MAIN \ -c android.intent.category.LAUNCHER \ -f 0x10200000 \ -n com.example.app/.MainActivity 

The action, category and component come from the same dump, so the check doesn't need to know anything about your app.

A pass has to prove something

A process death test that passes tells you very little unless it also checks that the event happened. Around the restore, the check verifies five things:

  1. The old processes are gone. It waits until ps no longer lists any process of the package, including pkg:service ones.
  2. The PID changed. If it didn't, the kill can't be proven.
  3. The same Activity came back in the same task. Landing on a different Activity is a finding, even with no crash.
  4. Nothing crashed. It reads logcat from the moment of the kill, then waits a little longer, because a crash in onResume or in the first frame arrives late.
  5. Typed text and toggles match. It compares two uiautomator dumps and skips password fields.

There is one more decision I'd copy into any device test: "the check could not run" and "the check found a bug" are different results. pdcheck exits with 1 for a bug and 2 when it couldn't get a reliable answer, for example when the window is unreadable after the restore. A broken emulator shouldn't look like a bug in the app, and it definitely shouldn't look like a pass.

Running it

Open a screen, type something and run:

pdcheck kill -p com.example.app 

On the sample app's form that keeps its input in remember, the result looks like this:

 Saved state acknowledged for 2 Activity(s) Killed dev.byfedin.pdcheck.sample (5244) with RUN_AS FAIL dev.byfedin.pdcheck.sample/dev.byfedin.pdcheck.sample.ForgetfulFormActivity - UI state lost after process death (2) field#1: "hello" -> "" toggle:Urgent: "checked" -> "unchecked" 

Checking screens one by one gets old quickly, so there is also a sweep. It taps through whatever it can reach and runs the check on each screen, with no test script. In a Gradle project:

plugins { id("com.android.application") id("dev.byfedin.pdcheck") version "0.1.0" } 
./gradlew :app:pdcheckSweepDebug 

It writes JUnit XML, so a failed check shows up in CI like a failed test. The project's own CI runs it against the sample on API 26 and API 36 emulators.

What it won't do

  • run-as needs a debuggable build. Release builds fall back to am kill, which only kills a process the system already considers safe to kill.
  • The sweep presses whatever buttons it finds, so point it at a test account. It never types, which means it stops at sign-in screens.
  • In a single-Activity app it compares the Activity, not the Compose destination. Falling back to the start destination is only caught if a field or toggle on the lost screen had a value.
  • Reloading a list after process death is legitimate, so loaded data and scroll position are out of scope. Assert those in your own tests.

A good first target is any screen that reads a singleton, or one that's normally reached through several others. Run pdcheck kill there once. The code is on GitHub under MIT, and if it gets your app wrong, an issue with the dumpsys output from the report folder is the most useful thing you can send.