create-cmp-cli 0.11.0 → 0.13.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.
- package/README.md +11 -9
- package/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +51 -6
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- 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
|
+
}
|
package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt
ADDED
|
@@ -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
|
+
}
|