create-cmp-cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/scaffold.mjs +7 -2
  5. package/template/.claude/settings.json +30 -0
  6. package/template/CLAUDE.md +48 -6
  7. package/template/composeApp/build.gradle.kts +44 -0
  8. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  9. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  10. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  11. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  12. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  25. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  26. package/template/docs/ARCHITECTURE.md +41 -2
  27. package/template/docs/TESTING.md +165 -0
  28. package/template/gradle/libs.versions.toml +15 -0
  29. package/template/manifest.json +1 -0
  30. package/template/qa/evidence/schema.json +20 -2
  31. package/template/qa/lib/affected-tests.mjs +147 -0
  32. package/template/qa/lib/device-lease.mjs +249 -0
  33. package/template/qa/lib/evidence-level.mjs +117 -0
  34. package/template/qa/lib/spec-coverage.mjs +54 -3
  35. package/template/qa/lib/step-cache.mjs +221 -0
  36. package/template/qa/receipt-check.mjs +22 -2
  37. package/template/qa/scaffold-feature.mjs +20 -1
  38. package/template/qa/verify.mjs +637 -56
  39. package/template/qa/watch.mjs +622 -0
  40. package/template/specs/app-base.spec.md +11 -0
@@ -0,0 +1,163 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.app.NotificationManager
4
+ import android.content.Context
5
+ import android.os.Build
6
+ import android.service.notification.StatusBarNotification
7
+ import androidx.test.platform.app.InstrumentationRegistry
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Assert.fail
10
+ import org.junit.Assume.assumeTrue
11
+
12
+ /**
13
+ * Notification assertions against the REAL NotificationManager — the shade as the OS sees
14
+ * it, not as the app hopes it is.
15
+ *
16
+ * Why this exists: "the notification posted" is an asynchronous OS-side fact. Posting is
17
+ * fire-and-forget, delivery is ranked/filtered/deferred, and none of it is observable from
18
+ * a JVM test. The recurring production failures this file targets are all of the shape
19
+ * "the code ran, the phone stayed dark": a channel created with the wrong importance, a
20
+ * full-screen intent silently dropped, a re-post under a changed channel id that Android
21
+ * discards without error.
22
+ *
23
+ * Constraints the code can't show:
24
+ * - `activeNotifications` only returns notifications the OS ACCEPTED. On API 33+ a fresh
25
+ * install has no POST_NOTIFICATIONS grant and everything is dropped before it reaches
26
+ * the shade — take the grant in your test's @Before via
27
+ * `uiAutomation.grantRuntimePermission(pkg, "android.permission.POST_NOTIFICATIONS")`
28
+ * (see PlatformBehaviorSeamTest). A suite that forgets the grant reports the exact bug
29
+ * it exists to catch, so [awaitNotification]'s failure message reminds you.
30
+ * - Posting is asynchronous; every positive assertion here is a bounded poll, never a
31
+ * single read. The negative check ([assertNoNotification]) polls the FULL window —
32
+ * absence is only meaningful after the post would have landed.
33
+ * - Channel behavior (sound, vibration, DND bypass) is partly OS/OEM-owned: importance is
34
+ * app-controlled at creation, but `lockscreenVisibility`/`bypassDnd`/sound routing are
35
+ * "modifiable by the system and the ranker" and read back as defaults regardless of
36
+ * what you set. Assert what the app controls; leave the OEM half to the manual tier
37
+ * (see docs/TESTING.md).
38
+ */
39
+ object NotificationAsserts {
40
+
41
+ private val context: Context
42
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
43
+
44
+ private val manager: NotificationManager
45
+ get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
46
+
47
+ private const val DEFAULT_TIMEOUT_MS = 10_000L
48
+ private const val POLL_MS = 100L
49
+
50
+ /**
51
+ * Waits (bounded poll, [timeoutMs]) for a posted notification matching [predicate] and
52
+ * returns it. Fails with the current shade contents — the diff you actually need.
53
+ */
54
+ fun awaitNotification(
55
+ timeoutMs: Long = DEFAULT_TIMEOUT_MS,
56
+ predicate: (StatusBarNotification) -> Boolean,
57
+ ): StatusBarNotification {
58
+ val deadline = System.currentTimeMillis() + timeoutMs
59
+ while (System.currentTimeMillis() < deadline) {
60
+ manager.activeNotifications.firstOrNull(predicate)?.let { return it }
61
+ Thread.sleep(POLL_MS)
62
+ }
63
+ fail(
64
+ "no matching notification posted within ${timeoutMs}ms. " +
65
+ "Shade holds: ${describeShade()}. " +
66
+ "If the shade is empty on API 33+, check the POST_NOTIFICATIONS grant first.",
67
+ )
68
+ error("unreachable")
69
+ }
70
+
71
+ /** [awaitNotification] by the (id, tag) pair the app posted under. */
72
+ fun awaitNotification(
73
+ id: Int,
74
+ tag: String? = null,
75
+ timeoutMs: Long = DEFAULT_TIMEOUT_MS,
76
+ ): StatusBarNotification =
77
+ awaitNotification(timeoutMs) { it.id == id && it.tag == tag }
78
+
79
+ /**
80
+ * Asserts NOTHING matching [predicate] is in the shade for the full [windowMs].
81
+ * Deliberately slow: a notification that appears 2s late is still a failure, so the
82
+ * whole window is watched — use this sparingly, for "the cancel actually cancelled".
83
+ */
84
+ fun assertNoNotification(
85
+ windowMs: Long = 3_000L,
86
+ predicate: (StatusBarNotification) -> Boolean,
87
+ ) {
88
+ val deadline = System.currentTimeMillis() + windowMs
89
+ while (System.currentTimeMillis() < deadline) {
90
+ val hit = manager.activeNotifications.firstOrNull(predicate)
91
+ if (hit != null) fail("unexpected notification in the shade: ${describe(hit)}")
92
+ Thread.sleep(POLL_MS)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Asserts the channel exists with AT LEAST [importanceFloor]. A floor, not equality:
98
+ * the user can raise a channel's importance and the app must keep working — what the
99
+ * app must guarantee is the minimum it created the channel with.
100
+ *
101
+ * The classic escaped bug: a heads-up channel created as IMPORTANCE_DEFAULT renders as
102
+ * a silent shade line — code identical, behavior invisible to every JVM tier.
103
+ *
104
+ * Channels exist from API 26; on older devices the test is skipped (assumption), never
105
+ * vacuously green.
106
+ */
107
+ fun assertChannelExists(
108
+ channelId: String,
109
+ importanceFloor: Int = NotificationManager.IMPORTANCE_DEFAULT,
110
+ ) {
111
+ assumeTrue(
112
+ "notification channels need API 26+ (device is ${Build.VERSION.SDK_INT})",
113
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O,
114
+ )
115
+ val channel = manager.getNotificationChannel(channelId)
116
+ ?: fail(
117
+ "notification channel '$channelId' does not exist. Channels present: " +
118
+ manager.notificationChannels.joinToString { it.id },
119
+ ).let { error("unreachable") }
120
+ assertTrue(
121
+ "channel '$channelId' importance is ${channel.importance}, below the required " +
122
+ "floor $importanceFloor — it will not behave (heads-up/sound) the way the " +
123
+ "feature assumes",
124
+ channel.importance >= importanceFloor,
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Asserts the app is currently CAPABLE of a full-screen (lock-screen takeover)
130
+ * notification on [channelId]:
131
+ * - the channel exists at IMPORTANCE_HIGH or above (below HIGH the OS won't launch
132
+ * the full-screen intent, it just posts quietly), and
133
+ * - on API 34+, `canUseFullScreenIntent()` — Android 14 turned USE_FULL_SCREEN_INTENT
134
+ * into a revocable special access, so a manifest permission alone stopped being
135
+ * proof. On 24..33 the manifest grant is install-time and not queryable, so only
136
+ * the channel half is asserted there.
137
+ *
138
+ * This is capability, not delivery: whether a takeover actually renders over the lock
139
+ * screen on a locked, Doze-ing, OEM-skinned device belongs to the manual tier.
140
+ */
141
+ fun assertFullScreenIntentCapable(channelId: String) {
142
+ assertChannelExists(channelId, NotificationManager.IMPORTANCE_HIGH)
143
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
144
+ assertTrue(
145
+ "canUseFullScreenIntent() is false — Android 14+ treats full-screen intents " +
146
+ "as revocable special access; declare USE_FULL_SCREEN_INTENT in the " +
147
+ "manifest and (for alarm/call apps outside the auto-grant) send the user " +
148
+ "to ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT",
149
+ manager.canUseFullScreenIntent(),
150
+ )
151
+ }
152
+ }
153
+
154
+ private fun describeShade(): String {
155
+ val active = manager.activeNotifications
156
+ return if (active.isEmpty()) "(empty)" else active.joinToString { describe(it) }
157
+ }
158
+
159
+ private fun describe(sbn: StatusBarNotification): String =
160
+ "[id=${sbn.id} tag=${sbn.tag} channel=${
161
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) sbn.notification.channelId else "n/a"
162
+ }]"
163
+ }
@@ -0,0 +1,132 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.content.pm.PackageManager
4
+ import android.content.pm.PermissionInfo
5
+ import androidx.test.platform.app.InstrumentationRegistry
6
+ import org.junit.Assert.fail
7
+ import org.junit.Assume.assumeTrue
8
+
9
+ /**
10
+ * Runtime-permission control for the on-device tier — make "what happens when the user
11
+ * denies it?" a test input instead of a support ticket.
12
+ *
13
+ * Why this exists: the recurring production shape is an app that assumes its grant. A
14
+ * fresh install on API 33+ holds NO notification grant, every `notify()` is dropped
15
+ * before it reaches the shade, and all of it is silent — the code runs, the phone stays
16
+ * dark, and every JVM tier stays green. The denied state is the DEFAULT state for a new
17
+ * user, and it was the one state no test visited.
18
+ *
19
+ * THE TRAP this file must document rather than hide — revoking kills the process:
20
+ * revoking a runtime permission an app currently HOLDS kills that app's process, by OS
21
+ * design (a process must not keep using a permission it lost). Verified live on a stock
22
+ * API 35 emulator: `pm revoke <pkg> POST_NOTIFICATIONS` against a running holder killed
23
+ * its pid on the spot. In this seam the instrumentation lives IN the app's process, so
24
+ * "revoke my own held permission mid-test" is not a test step, it is the test shooting
25
+ * itself: the run dies as "Process crashed", attributing the kill to nothing. That is
26
+ * why this organ ships NO revoke bracket — [withPermissionDenied] instead runs your
27
+ * block when the permission is already denied (the honest, reachable version of the
28
+ * state) and SKIPs with the full story when it is not. To genuinely un-grant between
29
+ * runs, do it from OUTSIDE the process — `adb shell pm revoke <pkg> <permission>` from a
30
+ * terminal (the app dies; that is the documented behavior, happening where it can be
31
+ * seen) — or uninstall/reinstall; grants survive `install -r` but not uninstall.
32
+ *
33
+ * The second trap — only RUNTIME permissions are controllable, and failure is silent:
34
+ * `pm grant` on an install-time permission (INTERNET) throws a SecurityException that
35
+ * UiAutomation's channel cannot even see (stderr is dropped — [Shell]), and on an
36
+ * UNDECLARED permission it prints nothing and exits 0, a verified silent no-op. So
37
+ * [grantPermission] never trusts the command: it re-reads the grant, and on failure
38
+ * diagnoses WHICH misuse happened (undeclared vs install-time vs genuinely refused)
39
+ * from PackageManager instead of from output that does not exist.
40
+ */
41
+ object PermissionControl {
42
+
43
+ private val context
44
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
45
+
46
+ /** True when [permission] is currently granted to the app under test. */
47
+ fun isGranted(permission: String): Boolean =
48
+ context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
49
+
50
+ /**
51
+ * Grant [permission] to the app under test via `pm grant`, verified by re-reading
52
+ * the grant — never by trusting the command (see the header for why its failures
53
+ * are invisible). ONE-WAY by design: there is no restoring revoke, because the
54
+ * revoke would kill the process doing the restoring. The grant persists until the
55
+ * app is uninstalled; a test that needs the denied state must run before anything
56
+ * grants (see [withPermissionDenied]).
57
+ */
58
+ fun grantPermission(permission: String) {
59
+ Shell.assumeOnEmulator(
60
+ "PermissionControl runs only on emulators (ro.kernel.qemu != 1 here). A " +
61
+ "grant on a real phone persists until uninstall — state the owner did " +
62
+ "not choose. Run this suite on a stock QA AVD instead.",
63
+ )
64
+ if (isGranted(permission)) return
65
+ Shell.exec("pm grant ${context.packageName} $permission")
66
+ if (!isGranted(permission)) {
67
+ fail(
68
+ "pm grant did not grant '$permission' — ${diagnoseUngrantable(permission)}",
69
+ )
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Run [block] with [permission] DENIED — the fresh-install default on API 33+, and
75
+ * the state most escaped bugs live in. Two honest branches:
76
+ *
77
+ * - Already denied: the block runs directly. Nothing is mutated, nothing needs
78
+ * restoring — this bracket is safe anywhere, so it carries no emulator guard.
79
+ * - Currently granted: SKIP (assumption violation), because the only path from
80
+ * granted to denied kills this very process (header). The skip message carries
81
+ * the outside-the-process command that reaches the state for real.
82
+ *
83
+ * Suite-order honesty: anything that grants — including the seam's own
84
+ * POST_NOTIFICATIONS setup — moves later tests into the skip branch, and the grant
85
+ * outlives the run. The denied state is guaranteed only on a fresh install; a SKIP
86
+ * here is the harness telling you that, not a flake.
87
+ */
88
+ fun <T> withPermissionDenied(permission: String, block: () -> T): T {
89
+ assumeTrue(
90
+ "'$permission' is currently GRANTED, and revoking a held permission kills " +
91
+ "the holding process — this very test. Reach the denied state from " +
92
+ "outside the process instead: `adb shell pm revoke " +
93
+ "${context.packageName} $permission` (the app is killed; that is the " +
94
+ "documented OS behavior), or uninstall and reinstall, then re-run.",
95
+ !isGranted(permission),
96
+ )
97
+ return block()
98
+ }
99
+
100
+ /**
101
+ * Name the reason a grant could not land, from PackageManager truth — the shell's
102
+ * own error surface is stderr, which the instrumentation channel drops.
103
+ */
104
+ private fun diagnoseUngrantable(permission: String): String {
105
+ val requested = context.packageManager
106
+ .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
107
+ .requestedPermissions?.toList().orEmpty()
108
+ if (permission !in requested) {
109
+ return "it is not declared in the manifest, and pm grant on an undeclared " +
110
+ "permission is a verified silent no-op (no output, exit 0). Declare it " +
111
+ "with <uses-permission> first."
112
+ }
113
+ val protection = try {
114
+ val info = context.packageManager.getPermissionInfo(permission, 0)
115
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
116
+ info.protection
117
+ } else {
118
+ @Suppress("DEPRECATION")
119
+ info.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE
120
+ }
121
+ } catch (e: PackageManager.NameNotFoundException) {
122
+ return "the permission itself does not exist on this device/API level."
123
+ }
124
+ if (protection != PermissionInfo.PROTECTION_DANGEROUS) {
125
+ return "it is not a runtime permission (protection level $protection): " +
126
+ "install-time permissions are 'not a changeable permission type' — " +
127
+ "granted at install or never, and pm grant/revoke cannot touch them."
128
+ }
129
+ return "it is a declared runtime permission, so this is a genuine refusal — " +
130
+ "check `dumpsys package ${context.packageName}` for a policy-fixed flag."
131
+ }
132
+ }
@@ -0,0 +1,217 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.app.Activity
4
+ import android.os.Build
5
+ import android.os.SystemClock
6
+ import androidx.test.platform.app.InstrumentationRegistry
7
+ import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
8
+ import androidx.test.runner.lifecycle.Stage
9
+ import org.junit.Assert.fail
10
+ import org.junit.Assume.assumeTrue
11
+
12
+ /**
13
+ * Process-death and activity-reclaim control for the on-device tier — the classic
14
+ * Android state-loss class ("came back from the background and everything was gone"),
15
+ * made a test input.
16
+ *
17
+ * THE TRAP this file must document rather than hide — you cannot kill the process your
18
+ * test lives in: instrumentation runs INSIDE the app's process, and the OS pins an
19
+ * instrumented process at foreground importance so the test infrastructure survives.
20
+ * `am kill` — the command that models real low-memory reclaim, because it kills only
21
+ * processes the OS itself considers reclaimable (background, no foreground activity) —
22
+ * is therefore a NO-OP against the app under test while a test runs in it. Verified
23
+ * live on a stock API 35 emulator: `am kill` against a foreground app left its pid
24
+ * untouched; against the same app backgrounded, the pid died. (`am force-stop` is the
25
+ * other command and the WRONG model twice over: it is the user's Settings "Force stop"
26
+ * — kills every process unconditionally, cancels the app's alarms and jobs, and marks
27
+ * the app stopped so nothing re-launches it — and in this seam it would take the test
28
+ * down with the app.) So the honest primitive is not "kill my own process"; it is
29
+ * making the OS destroy and rebuild what it actually destroys and rebuilds — the
30
+ * ACTIVITY — through the same save/restore path a process death exercises.
31
+ *
32
+ * [withDontKeepActivities] brackets exactly that: `always_finish_activities 1` is the
33
+ * developer setting Android ships for reproducing this bug class ("Don't keep
34
+ * activities"), and under it a backgrounded activity is REALLY destroyed — not
35
+ * config-change recreated: `isChangingConfigurations` is false, the ViewModelStore is
36
+ * cleared, non-config instances are dropped, and the return trip rebuilds the activity
37
+ * from its saved-instance-state Bundle. That is the state-restoration path a real
38
+ * process death takes, minus two things the header must not let a green test overclaim:
39
+ * - the process itself survives (verified: same pid across the round trip), so statics,
40
+ * singletons, and Application state are NOT re-initialized — a bug hiding in "my
41
+ * repository cache is a static" needs a true cold start to show;
42
+ * - saved-instance state lives in the ActivityManager, not on disk — reboot loses it.
43
+ * The true full-process rehearsal stays a two-command manual step, from OUTSIDE the
44
+ * process: background the app, `adb shell am kill <pkg>`, relaunch — which is exactly
45
+ * what this organ's in-process bracket cannot be, and says so.
46
+ *
47
+ * ONE MORE TRAP, verified live on a stock API 35 image: `settings put global
48
+ * always_finish_activities 1` alone is a runtime no-op. ActivityTaskManagerService reads
49
+ * that setting ONCE at boot; the Developer-options toggle works because it calls
50
+ * `IActivityManager.setAlwaysFinish(..)`, which flips the live in-memory flag AND
51
+ * persists the setting. Observed: with only the settings write in place, a freshly
52
+ * launched activity backgrounded to STOPPED and sat there un-destroyed indefinitely.
53
+ * [withDontKeepActivities] therefore makes the same binder call the toggle makes —
54
+ * reflection under the shell's adopted permission identity (the shell uid holds
55
+ * SET_ALWAYS_FINISH on stock images; verified granted on API 35) — and proves the call
56
+ * landed by re-reading the setting the call itself writes.
57
+ *
58
+ * All commands verified root-free from the shell uid on a stock user-build image:
59
+ * `input keyevent KEYCODE_HOME`, `am start -W -n <component>`, `am kill <pkg>`,
60
+ * `settings get/put/delete global always_finish_activities` (restore bookkeeping only —
61
+ * the live flag travels through the binder call above).
62
+ */
63
+ object ProcessControl {
64
+
65
+ private val instrumentation get() = InstrumentationRegistry.getInstrumentation()
66
+ private val context get() = instrumentation.targetContext
67
+
68
+ /**
69
+ * Run [block] with "Don't keep activities" on — the LIVE flag via
70
+ * `IActivityManager.setAlwaysFinish` (see the header: the settings write alone is a
71
+ * boot-time-only input), restoring both afterwards even when the block throws: the
72
+ * live flag is switched back, then a prior persisted value is put back and an absent
73
+ * one is deleted, not defaulted. SKIPs below API 29 (adoptShellPermissionIdentity is
74
+ * the root-free way to hold SET_ALWAYS_FINISH). Inside the block, [backgroundApp]
75
+ * destroys the foreground activity for real and [relaunchApp] brings it back through
76
+ * the saved-instance-state path — see RuntimeStateSeamTest for the exemplar shape
77
+ * (capture identity, plant a saved-state probe, background, await destruction,
78
+ * relaunch, assert a NEW instance resumed with the probe restored).
79
+ */
80
+ fun <T> withDontKeepActivities(block: () -> T): T {
81
+ Shell.assumeOnEmulator(
82
+ "ProcessControl runs only on emulators (ro.kernel.qemu != 1 here). " +
83
+ "Don't-keep-activities on a real phone destroys the owner's app state " +
84
+ "in every backgrounded app — run this suite on a stock QA AVD instead.",
85
+ )
86
+ assumeTrue(
87
+ "flipping the live always-finish flag needs adoptShellPermissionIdentity " +
88
+ "(API 29+); device is API ${Build.VERSION.SDK_INT}",
89
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
90
+ )
91
+ val prior = Shell.readSetting("global", "always_finish_activities")
92
+ setAlwaysFinish(true)
93
+ try {
94
+ return block()
95
+ } finally {
96
+ setAlwaysFinish(prior == "1")
97
+ Shell.restoreSetting("global", "always_finish_activities", prior)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The Developer-options toggle's own path: `IActivityManager.setAlwaysFinish`, called
103
+ * with the shell's adopted permission identity (shell holds SET_ALWAYS_FINISH). This
104
+ * is what actually updates ActivityTaskManagerService's in-memory flag at runtime —
105
+ * the settings provider write alone is read once at boot and never again (header).
106
+ * The call also persists the setting, which is the echo this method re-reads to prove
107
+ * the binder call landed rather than trusting it.
108
+ */
109
+ private fun setAlwaysFinish(enabled: Boolean) {
110
+ val uiAutomation = instrumentation.uiAutomation
111
+ uiAutomation.adoptShellPermissionIdentity("android.permission.SET_ALWAYS_FINISH")
112
+ try {
113
+ val service = Class.forName("android.app.ActivityManager")
114
+ .getMethod("getService")
115
+ .invoke(null)
116
+ Class.forName("android.app.IActivityManager")
117
+ .getMethod("setAlwaysFinish", java.lang.Boolean.TYPE)
118
+ .invoke(service, enabled)
119
+ } catch (e: ReflectiveOperationException) {
120
+ fail(
121
+ "IActivityManager.setAlwaysFinish is unreachable on this image " +
122
+ "($e) — the hidden-API surface moved, so the don't-keep bracket " +
123
+ "cannot flip the live flag; ProcessControl's header needs revisiting",
124
+ )
125
+ } finally {
126
+ uiAutomation.dropShellPermissionIdentity()
127
+ }
128
+ val echoed = Shell.readSetting("global", "always_finish_activities")
129
+ val expected = if (enabled) "1" else "0"
130
+ if (echoed != expected) {
131
+ fail(
132
+ "setAlwaysFinish($enabled) did not echo into the persisted setting " +
133
+ "(read '$echoed') — the binder call did not reach the " +
134
+ "ActivityTaskManager",
135
+ )
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Send the app to the background (HOME key via the shell — the user's own gesture),
141
+ * then wait (bounded) until no activity of the app is in the RESUMED stage. Under
142
+ * [withDontKeepActivities] this is the destruction trigger.
143
+ */
144
+ fun backgroundApp(timeoutMs: Long = 10_000L) {
145
+ Shell.exec("input keyevent KEYCODE_HOME")
146
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
147
+ while (SystemClock.elapsedRealtime() < deadline) {
148
+ if (resumedActivity() == null) return
149
+ Thread.sleep(100)
150
+ }
151
+ fail("the app still has a RESUMED activity ${timeoutMs}ms after HOME")
152
+ }
153
+
154
+ /**
155
+ * Relaunch the app's launcher activity (`am start -W -n`, resolved from the app's own
156
+ * launch intent — never hardcoded) and return the newly RESUMED activity instance.
157
+ * The instance is how the exemplar proves destruction happened: same instance back
158
+ * means nothing was destroyed and the bracket proved nothing.
159
+ */
160
+ fun relaunchApp(timeoutMs: Long = 10_000L): Activity {
161
+ val component = context.packageManager.getLaunchIntentForPackage(context.packageName)
162
+ ?.component
163
+ ?: fail("no launch intent for ${context.packageName} — nothing to relaunch")
164
+ .let { error("unreachable") }
165
+ Shell.exec("am start -W -n ${component.flattenToShortString()}")
166
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
167
+ while (SystemClock.elapsedRealtime() < deadline) {
168
+ resumedActivity()?.let { return it }
169
+ Thread.sleep(100)
170
+ }
171
+ fail("no activity reached RESUMED within ${timeoutMs}ms of am start")
172
+ error("unreachable")
173
+ }
174
+
175
+ /**
176
+ * Wait (bounded) until [activity] is genuinely destroyed. Backgrounding and
177
+ * destruction are two separate asynchronous steps — relaunching between them brings
178
+ * the SAME instance back and the bracket proves nothing, so the exemplar waits for
179
+ * this before relaunching.
180
+ */
181
+ fun awaitDestroyed(activity: Activity, timeoutMs: Long = 10_000L) {
182
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
183
+ while (SystemClock.elapsedRealtime() < deadline) {
184
+ var destroyed = false
185
+ instrumentation.runOnMainSync { destroyed = activity.isDestroyed }
186
+ if (destroyed) return
187
+ Thread.sleep(100)
188
+ }
189
+ fail(
190
+ "the backgrounded activity was not destroyed within ${timeoutMs}ms — is " +
191
+ "don't-keep-activities actually on (withDontKeepActivities), and was " +
192
+ "the app really backgrounded first?",
193
+ )
194
+ }
195
+
196
+ /** The app's currently RESUMED activity, or null (read on the main thread, as the monitor requires). */
197
+ fun resumedActivity(): Activity? {
198
+ var current: Activity? = null
199
+ instrumentation.runOnMainSync {
200
+ current = ActivityLifecycleMonitorRegistry.getInstance()
201
+ .getActivitiesInStage(Stage.RESUMED).firstOrNull()
202
+ }
203
+ return current
204
+ }
205
+
206
+ /**
207
+ * `am kill` — the low-memory-reclaim model, exposed for the one thing it can honestly
208
+ * do in this seam: kill the app's AUXILIARY processes (components declared with
209
+ * `android:process`), which are not pinned the way the instrumented main process is.
210
+ * Against the main process it is a no-op while the test runs (header); the exemplar
211
+ * asserts that fact live, so the day an OS change breaks the assumption, the suite
212
+ * says so instead of this comment silently rotting.
213
+ */
214
+ fun killReclaimableProcesses(packageName: String = context.packageName) {
215
+ Shell.exec("am kill $packageName")
216
+ }
217
+ }
@@ -0,0 +1,79 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.os.ParcelFileDescriptor
4
+ import androidx.test.platform.app.InstrumentationRegistry
5
+ import org.junit.Assume.assumeTrue
6
+
7
+ /**
8
+ * The one shell channel for the on-device tier. Every organ in this package — the asserts
9
+ * that READ device state ([AlarmAsserts], [NotificationAsserts]) and the controls that
10
+ * MUTATE it ([TimeWarp], [DozeControl], [PermissionControl], [ProcessControl],
11
+ * [NetworkControl], [ConfigControl]) — goes through [exec], and every mutating organ gates
12
+ * on [assumeOnEmulator]. One channel, one guard, so a new organ can't quietly invent a
13
+ * weaker version of either.
14
+ *
15
+ * Why the shell at all: the states these organs put the device into (idle, offline,
16
+ * permission-denied, dark, warped clock) are deliberately not reachable from app APIs —
17
+ * they are the OS's side of the contract. The instrumentation's UiAutomation runs its
18
+ * shell commands with the SHELL uid, which is privileged enough for all of them and
19
+ * available on every stock emulator image. No root: `adb root` is refused on production
20
+ * (user-build) images, including the stock Play-image AVDs, and every command an organ
21
+ * issues has been verified to work without it.
22
+ *
23
+ * Constraints the code can't show:
24
+ * - STDOUT ONLY. UiAutomation's channel returns the command's stdout and silently drops
25
+ * stderr. This is not cosmetic: `pm grant` on an install-time permission prints its
26
+ * SecurityException to stderr (invisible here), and `pm grant` on an UNDECLARED
27
+ * permission prints nothing anywhere and exits 0 — a silent no-op (both verified on a
28
+ * stock API 35 image). An organ must therefore prove its command worked by re-reading
29
+ * the state it changed, never by trusting output or exit status it cannot see.
30
+ * - No shell interpretation. The command string is tokenized, not run through `sh -c` —
31
+ * no quoting, no pipes, no redirection. Compose commands accordingly (every organ's
32
+ * commands are plain token lists).
33
+ */
34
+ object Shell {
35
+
36
+ /** Runs [command] via UiAutomation (shell uid) and returns its full stdout. */
37
+ fun exec(command: String): String {
38
+ val pfd = InstrumentationRegistry.getInstrumentation().uiAutomation
39
+ .executeShellCommand(command)
40
+ ParcelFileDescriptor.AutoCloseInputStream(pfd).use { stream ->
41
+ return stream.readBytes().decodeToString()
42
+ }
43
+ }
44
+
45
+ /** True on an emulator (`ro.kernel.qemu == 1`) — the property every guard checks. */
46
+ fun isEmulator(): Boolean = exec("getprop ro.kernel.qemu").trim() == "1"
47
+
48
+ /**
49
+ * SKIPs the test (assumption violation, never a failure) unless it runs on an
50
+ * emulator. Every state-mutating organ calls this first with its own message saying
51
+ * what it would have done to a real phone's owner — a QA emulator is disposable
52
+ * state; a person's device never is. Call it at the top of your own test too, so the
53
+ * skip names the test rather than a helper frame.
54
+ */
55
+ fun assumeOnEmulator(message: String) {
56
+ assumeTrue(message, isEmulator())
57
+ }
58
+
59
+ /**
60
+ * A setting's current value, or null when unset — `settings get` prints the literal
61
+ * string "null" for absent keys, and this maps it back to the truth.
62
+ */
63
+ fun readSetting(namespace: String, key: String): String? =
64
+ exec("settings get $namespace $key").trim()
65
+ .takeUnless { it.isEmpty() || it == "null" }
66
+
67
+ /**
68
+ * Restore a setting to the state [readSetting] captured: a prior value is put back,
69
+ * an absent one is DELETED — putting a guessed default where none existed is itself
70
+ * drift, and the next test would inherit it.
71
+ */
72
+ fun restoreSetting(namespace: String, key: String, prior: String?) {
73
+ if (prior == null) {
74
+ exec("settings delete $namespace $key")
75
+ } else {
76
+ exec("settings put $namespace $key $prior")
77
+ }
78
+ }
79
+ }