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.
Files changed (51) hide show
  1. package/README.md +11 -9
  2. package/bin/create-cmp.mjs +3 -0
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +287 -0
  5. package/src/lib/harness-upgrade.mjs +364 -0
  6. package/src/lib/package-name.mjs +72 -0
  7. package/src/scaffold.mjs +7 -2
  8. package/template/.claude/settings.json +30 -0
  9. package/template/CLAUDE.md +51 -6
  10. package/template/README.md +4 -0
  11. package/template/composeApp/build.gradle.kts +44 -0
  12. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  29. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  30. package/template/docs/ARCHITECTURE.md +41 -2
  31. package/template/docs/TESTING.md +165 -0
  32. package/template/gradle/libs.versions.toml +15 -0
  33. package/template/manifest.json +1 -0
  34. package/template/qa/evidence/schema.json +20 -2
  35. package/template/qa/lib/affected-tests.mjs +147 -0
  36. package/template/qa/lib/audit-cadence.mjs +290 -0
  37. package/template/qa/lib/determinism.mjs +179 -0
  38. package/template/qa/lib/device-lease.mjs +249 -0
  39. package/template/qa/lib/evidence-badge.mjs +158 -0
  40. package/template/qa/lib/evidence-level.mjs +117 -0
  41. package/template/qa/lib/flight-recorder.mjs +332 -0
  42. package/template/qa/lib/inputs-hash.mjs +16 -1
  43. package/template/qa/lib/spec-coverage.mjs +54 -3
  44. package/template/qa/lib/step-cache.mjs +221 -0
  45. package/template/qa/receipt-check.mjs +22 -2
  46. package/template/qa/record-audit.mjs +83 -0
  47. package/template/qa/retrospective.mjs +51 -0
  48. package/template/qa/scaffold-feature.mjs +20 -1
  49. package/template/qa/verify.mjs +934 -57
  50. package/template/qa/watch.mjs +622 -0
  51. package/template/specs/app-base.spec.md +11 -0
@@ -161,6 +161,24 @@ kotlin {
161
161
  implementation(compose.desktop.currentOs)
162
162
  }
163
163
  }
164
+
165
+ // Android INSTRUMENTATION tier (composeApp/src/androidInstrumentedTest) — the one
166
+ // evidence tier that crosses the process boundary. Everything else in this build is
167
+ // JVM-side: desktopTest is a JVM, golden trees are structure, the conformance suite
168
+ // is static analysis, and the Maestro smoke taps UI without asserting anything about
169
+ // notifications or alarms. Alarms, notification channels, full-screen intents,
170
+ // PendingIntent identity, and audio routing are OS facts that only exist on a device
171
+ // — a fully green desktop lane is compatible with an alerting feature that never
172
+ // alerts. This source set runs via `:composeApp:connectedDebugAndroidTest` (the
173
+ // lane's `androidChecks` step; SKIPs honestly when no device is attached).
174
+ //
175
+ // Its dependencies are declared through AGP's androidTestImplementation
176
+ // configuration (the dependencies block at the bottom of this file), not here: the
177
+ // KMP source-set DSL compiles these sources but does not put the androidx.test
178
+ // artifacts on their classpath.
179
+ //
180
+ // No kotlin-test here: instrumented tests run under JUnit4 (AndroidJUnit4), so they
181
+ // assert with org.junit.Assert — a second assertion vocabulary buys nothing.
164
182
  }
165
183
  }
166
184
 
@@ -174,6 +192,9 @@ android {
174
192
  targetSdk = 35
175
193
  versionCode = 1
176
194
  versionName = "1.0.0"
195
+ // Instrumentation entry point for the on-device behavior tier
196
+ // (composeApp/src/androidInstrumentedTest — see the source-set note above).
197
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
177
198
  }
178
199
 
179
200
  packaging {
@@ -182,6 +203,20 @@ android {
182
203
  }
183
204
  }
184
205
 
206
+ // AGP resolves BUILD-TYPE source sets at src/<buildType>/, while the Kotlin
207
+ // Multiplatform plugin only remaps the `main` one to src/androidMain/. Without this
208
+ // wiring, src/androidDebug/'s manifest and resources are silently never merged —
209
+ // dead files that look live: the debug network-security config never applied, and a
210
+ // permission declared there never reached the APK. Point the debug build type at them
211
+ // explicitly. Caught when an instrumented test asserted canScheduleExactAlarms() and
212
+ // found the grant it had declared was absent on the device.
213
+ sourceSets {
214
+ getByName("debug") {
215
+ manifest.srcFile("src/androidDebug/AndroidManifest.xml")
216
+ res.srcDirs("src/androidDebug/res")
217
+ }
218
+ }
219
+
185
220
  buildFeatures {
186
221
  buildConfig = true
187
222
  }
@@ -269,6 +304,15 @@ dependencies {
269
304
  add("kspDesktop", libs.room.compiler)
270
305
  // <<< cmp:feature room
271
306
  add("coreLibraryDesugaring", libs.android.desugar.jdk)
307
+ // The instrumentation tier's runner, JUnit4 harness, and device helpers. Declared
308
+ // through AGP's own configuration rather than the KMP androidInstrumentedTest
309
+ // source-set block — the latter compiles the sources but does not put these
310
+ // artifacts on their classpath.
311
+ add("androidTestImplementation", libs.androidx.test.runner)
312
+ add("androidTestImplementation", libs.androidx.test.core)
313
+ add("androidTestImplementation", libs.androidx.test.ext.junit)
314
+ add("androidTestImplementation", libs.androidx.uiautomator)
315
+ add("androidTestImplementation", libs.junit4)
272
316
  }
273
317
 
274
318
  // Pin the generated resources accessor package so `__PACKAGE__.generated.resources.Res`
@@ -7,6 +7,15 @@
7
7
  <uses-permission android:name="android.permission.INTERNET" />
8
8
  <!-- <<< cmp:feature inspector -->
9
9
 
10
+ <!-- The instrumented-tier exact-alarm proof (PlatformBehaviorSeamTest, TimeWarp) needs
11
+ setExactAndAllowWhileIdle to actually schedule. API 31/32 gate it behind
12
+ SCHEDULE_EXACT_ALARM, which the system grants by default there; API 33+ gates it
13
+ behind USE_EXACT_ALARM, granted at install. Neither belongs in the shipped
14
+ manifest — a real app that wants exact alarms declares that choice itself; this is
15
+ the test harness's own permission, debug-only. -->
16
+ <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
17
+ <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
18
+
10
19
  <application
11
20
  android:networkSecurityConfig="@xml/debug_network_security_config"
12
21
  android:usesCleartextTraffic="true" />
@@ -0,0 +1,277 @@
1
+ package __PACKAGE__
2
+
3
+ import android.app.AlarmManager
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.content.BroadcastReceiver
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.IntentFilter
11
+ import android.app.PendingIntent
12
+ import android.os.Build
13
+ import android.os.SystemClock
14
+ import android.util.Log
15
+ import androidx.test.core.app.ActivityScenario
16
+ import androidx.test.ext.junit.runners.AndroidJUnit4
17
+ import androidx.test.platform.app.InstrumentationRegistry
18
+ import __PACKAGE__.testing.AlarmAsserts
19
+ import __PACKAGE__.testing.NotificationAsserts
20
+ import __PACKAGE__.testing.TimeWarp
21
+ import java.util.concurrent.CountDownLatch
22
+ import java.util.concurrent.TimeUnit
23
+ import java.util.concurrent.atomic.AtomicLong
24
+ import org.junit.After
25
+ import org.junit.Assert.assertNotNull
26
+ import org.junit.Assert.assertTrue
27
+ import org.junit.Assume.assumeTrue
28
+ import org.junit.Before
29
+ import org.junit.Test
30
+ import org.junit.runner.RunWith
31
+
32
+ /**
33
+ * The exemplar for the on-device behavior tier — and the proof that the seam works.
34
+ *
35
+ * This seam exists because platform behavior escapes every desktop tier; when your feature
36
+ * touches alarms, notifications, or locks, its behavior test lives here. Desktop unit tests
37
+ * run on a JVM, golden trees pin structure, the conformance suite is static, and the
38
+ * Maestro smoke taps UI without asserting anything about the notification shade or the OS
39
+ * alarm table — so a feature whose whole point is "the phone alerts" can ship fully green
40
+ * and never alert. In two real apps built on this template, that exact class produced nine
41
+ * escaped defects across multiple releases; the hand-built version of this source set
42
+ * caught two more bugs the week it landed.
43
+ *
44
+ * What the template can honestly exemplify: the stamped app ships no notification or alarm
45
+ * feature yet, so these tests assert universal facts through the app's REAL process on a
46
+ * real device — the app boots (its Android DI graph resolves for real, which desktop fakes
47
+ * cannot prove), and the tier's helpers observe true OS state (a posted notification is
48
+ * seen in the shade, a scheduled alarm is seen in the alarm table, and the
49
+ * PendingIntent-identity collision is demonstrated live). When your first feature posts
50
+ * its own notification, replace the direct NotificationManager call below with your
51
+ * feature's real path and keep the assertions — that is the whole pattern.
52
+ *
53
+ * Runs via `:composeApp:connectedDebugAndroidTest` — the verify lane's `androidChecks`
54
+ * step (SKIPs when no device is attached; cite platform-behavior spec clauses from here).
55
+ */
56
+ @RunWith(AndroidJUnit4::class)
57
+ class PlatformBehaviorSeamTest {
58
+
59
+ private val context: Context
60
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
61
+
62
+ private val notificationManager: NotificationManager
63
+ get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
64
+
65
+ private val alarmManager: AlarmManager
66
+ get() = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
67
+
68
+ @Before
69
+ fun setUp() {
70
+ // A fresh install has no POST_NOTIFICATIONS grant on API 33+, and without it every
71
+ // post is dropped before it reaches the shade — the notification test would then
72
+ // fail about the harness, not the app. The grant is taken, never hoped for.
73
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
74
+ runCatching {
75
+ InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
76
+ context.packageName,
77
+ "android.permission.POST_NOTIFICATIONS",
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ @After
84
+ fun tearDown() {
85
+ // Leave the device as found: this tier asserts real OS state, so real OS state
86
+ // must be cleaned up — a leaked notification or alarm pollutes the NEXT test run.
87
+ notificationManager.cancelAll()
88
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
89
+ notificationManager.deleteNotificationChannel(PROBE_CHANNEL)
90
+ }
91
+ listOf(REQUEST_A, REQUEST_B, REQUEST_SHARED).forEach { code ->
92
+ alarmManager.cancel(probePendingIntent(code, ACTION_PROBE))
93
+ }
94
+ alarmManager.cancel(probePendingIntent(REQUEST_WARP, ACTION_WARP))
95
+ }
96
+
97
+ @Test
98
+ fun theRealAppBootsOnTheDevice() {
99
+ // Not a tautology: this launches MainActivity in the app's real process with the
100
+ // real AppApplication — Koin's ANDROID graph resolves for real (desktop DI
101
+ // substitutes fakes, so an androidMain-only wiring bug is invisible to every other
102
+ // tier; a real app on this template shipped exactly that crash). If the app cannot
103
+ // boot, nothing else in this tier means anything, so this is the tier's smoke.
104
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
105
+ assertNotNull(scenario.state)
106
+ }
107
+ }
108
+
109
+ @Test
110
+ fun aPostedNotificationIsVisibleToTheShadeAndItsChannelHoldsItsImportance() {
111
+ assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
112
+ // The template has no notification feature yet, so the post goes through
113
+ // NotificationManager directly — proving the helpers see true OS state. In your
114
+ // feature's test, this arrange step becomes a call into YOUR notification path.
115
+ notificationManager.createNotificationChannel(
116
+ NotificationChannel(PROBE_CHANNEL, "Seam probe", NotificationManager.IMPORTANCE_HIGH),
117
+ )
118
+ notificationManager.notify(
119
+ PROBE_ID,
120
+ Notification.Builder(context, PROBE_CHANNEL)
121
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
122
+ .setContentTitle("seam probe")
123
+ .build(),
124
+ )
125
+
126
+ // The assertions cross the process boundary: the OS accepted the channel at the
127
+ // importance the app created it with, and the notification actually reached the
128
+ // shade — the two facts that silently broke in production, repeatedly.
129
+ NotificationAsserts.assertChannelExists(
130
+ PROBE_CHANNEL,
131
+ importanceFloor = NotificationManager.IMPORTANCE_HIGH,
132
+ )
133
+ NotificationAsserts.awaitNotification(id = PROBE_ID)
134
+ }
135
+
136
+ @Test
137
+ fun twoLogicalAlarmsNeedTwoPendingIntentIdentities() {
138
+ val base = System.currentTimeMillis() + HOUR_MS
139
+
140
+ // Distinct request codes -> distinct PendingIntent identities -> two rows in the
141
+ // OS alarm table. This is the correct shape for "N independent schedules".
142
+ alarmManager.set(AlarmManager.RTC, base, probePendingIntent(REQUEST_A, ACTION_PROBE))
143
+ alarmManager.set(AlarmManager.RTC, base + 60_000, probePendingIntent(REQUEST_B, ACTION_PROBE))
144
+ AlarmAsserts.assertDistinctAlarms(
145
+ expected = 2,
146
+ what = "seam-probe alarms (distinct request codes)",
147
+ ) { it.whenMs == base || it.whenMs == base + 60_000 }
148
+
149
+ // The trap, demonstrated live: same request code + filter-equal intent (extras do
150
+ // NOT count) is ONE identity, so the second set() silently REPLACES the first —
151
+ // no error, no log, and only this table shows it. This exact shape shipped as two
152
+ // "independent" alarms sharing a slot.
153
+ val t3 = base + HOUR_MS
154
+ alarmManager.set(AlarmManager.RTC, t3, probePendingIntent(REQUEST_SHARED, ACTION_PROBE))
155
+ alarmManager.set(AlarmManager.RTC, t3 + 60_000, probePendingIntent(REQUEST_SHARED, ACTION_PROBE))
156
+ AlarmAsserts.assertDistinctAlarms(
157
+ expected = 1,
158
+ what = "seam-probe alarms (shared identity — the later set replaces the earlier)",
159
+ ) { it.whenMs == t3 || it.whenMs == t3 + 60_000 }
160
+ }
161
+
162
+ @Test
163
+ fun anExactAlarmScheduledForTheFutureActuallyDeliversWhenItsTimeArrives() {
164
+ // The proof shape for "my scheduled thing actually fires": registration says the
165
+ // OS *holds* the alarm ([twoLogicalAlarmsNeedTwoPendingIntentIdentities] stops
166
+ // there); this test warps the clock past the trigger time and watches DELIVERY —
167
+ // the onReceive that no other tier can observe, in seconds instead of hours. In
168
+ // your feature's test, the arrange step becomes YOUR scheduling path (the ladder
169
+ // that computes "tomorrow at 08:00") and the receiver becomes YOUR receiver —
170
+ // keep the shape: schedule → assert registered → warp past T → await delivery →
171
+ // assert the OS slot is consumed. Emulator-only via TimeWarp's guard.
172
+ TimeWarp.assumeOnEmulator()
173
+
174
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
175
+ // Exact alarms are permission-gated from API 31. The debug manifest declares
176
+ // SCHEDULE_EXACT_ALARM (≤32, granted by default there) and USE_EXACT_ALARM
177
+ // (33+, granted at install) — see androidDebug/AndroidManifest.xml for why
178
+ // they live in the debug manifest and not the shipped one.
179
+ assertTrue(
180
+ "canScheduleExactAlarms() is false — the debug-manifest exact-alarm " +
181
+ "grant (androidDebug/AndroidManifest.xml) is missing or was removed; " +
182
+ "setExactAndAllowWhileIdle would throw SecurityException",
183
+ alarmManager.canScheduleExactAlarms(),
184
+ )
185
+ }
186
+
187
+ val delivered = CountDownLatch(1)
188
+ val deliveredAtElapsed = AtomicLong(0)
189
+ val receiver = object : BroadcastReceiver() {
190
+ override fun onReceive(receiverContext: Context?, intent: Intent?) {
191
+ deliveredAtElapsed.set(SystemClock.elapsedRealtime())
192
+ delivered.countDown()
193
+ }
194
+ }
195
+ // Dynamic registration: the receiver lives exactly as long as the test, no
196
+ // manifest entry to leak. NOT_EXPORTED is correct because AlarmManager sends the
197
+ // PendingIntent's broadcast with this app's own identity (API 33+ requires the
198
+ // exported flag to be stated for context-registered receivers).
199
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
200
+ context.registerReceiver(
201
+ receiver,
202
+ IntentFilter(ACTION_WARP),
203
+ Context.RECEIVER_NOT_EXPORTED,
204
+ )
205
+ } else {
206
+ @Suppress("UnspecifiedRegisterReceiverFlag")
207
+ context.registerReceiver(receiver, IntentFilter(ACTION_WARP))
208
+ }
209
+
210
+ try {
211
+ // Far enough ahead that nothing fires before the warp; close enough that a
212
+ // registration-time sanity read stays cheap.
213
+ val target = System.currentTimeMillis() + 2 * 60_000L
214
+ alarmManager.setExactAndAllowWhileIdle(
215
+ AlarmManager.RTC_WAKEUP,
216
+ target,
217
+ probePendingIntent(REQUEST_WARP, ACTION_WARP),
218
+ )
219
+ AlarmAsserts.assertAlarmRegistered("the warp-probe exact alarm") {
220
+ it.whenMs == target
221
+ }
222
+
223
+ TimeWarp.withWarpedClock(target + 5_000L) {
224
+ val warpedAtElapsed = SystemClock.elapsedRealtime()
225
+ // Bounded on monotonic time (latch.await is nanoTime-based), so the
226
+ // warped wall clock cannot distort the timeout.
227
+ assertTrue(
228
+ "the exact alarm did not deliver within 60s of the clock warping " +
229
+ "past its trigger time — scheduled for $target, clock warped to " +
230
+ "${target + 5_000L}",
231
+ delivered.await(60, TimeUnit.SECONDS),
232
+ )
233
+ Log.i(
234
+ "PlatformBehaviorSeam",
235
+ "warp-probe alarm delivered ${deliveredAtElapsed.get() - warpedAtElapsed}ms " +
236
+ "after the clock warp",
237
+ )
238
+ }
239
+
240
+ // Delivery consumes the OS slot: the registry entry must be gone. Bounded
241
+ // poll on monotonic time — the table update can trail the broadcast slightly.
242
+ val goneDeadline = SystemClock.elapsedRealtime() + 10_000L
243
+ while (SystemClock.elapsedRealtime() < goneDeadline &&
244
+ AlarmAsserts.registeredAlarms().any { it.whenMs == target }
245
+ ) {
246
+ Thread.sleep(200)
247
+ }
248
+ assertTrue(
249
+ "the warp-probe alarm fired but its entry is still in the OS alarm table " +
250
+ "— a fired one-shot alarm must be consumed, not re-armed",
251
+ AlarmAsserts.registeredAlarms().none { it.whenMs == target },
252
+ )
253
+ } finally {
254
+ context.unregisterReceiver(receiver)
255
+ }
256
+ }
257
+
258
+ private fun probePendingIntent(requestCode: Int, action: String): PendingIntent =
259
+ PendingIntent.getBroadcast(
260
+ context,
261
+ requestCode,
262
+ Intent(action).setPackage(context.packageName),
263
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
264
+ )
265
+
266
+ private companion object {
267
+ const val PROBE_CHANNEL = "seam_probe"
268
+ const val PROBE_ID = 424_242
269
+ const val ACTION_PROBE = "cmp.seam.PROBE_ALARM"
270
+ const val REQUEST_A = 424_301
271
+ const val REQUEST_B = 424_302
272
+ const val REQUEST_SHARED = 424_303
273
+ const val ACTION_WARP = "cmp.seam.WARP_ALARM"
274
+ const val REQUEST_WARP = 424_304
275
+ const val HOUR_MS = 60L * 60 * 1000
276
+ }
277
+ }
@@ -0,0 +1,308 @@
1
+ package __PACKAGE__
2
+
3
+ import android.app.AlarmManager
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
8
+ import android.content.BroadcastReceiver
9
+ import android.content.Context
10
+ import android.content.Intent
11
+ import android.content.IntentFilter
12
+ import android.os.Build
13
+ import android.os.Bundle
14
+ import android.os.Process
15
+ import android.os.SystemClock
16
+ import androidx.savedstate.SavedStateRegistryOwner
17
+ import androidx.test.ext.junit.runners.AndroidJUnit4
18
+ import androidx.test.platform.app.InstrumentationRegistry
19
+ import __PACKAGE__.testing.AlarmAsserts
20
+ import __PACKAGE__.testing.DozeControl
21
+ import __PACKAGE__.testing.NotificationAsserts
22
+ import __PACKAGE__.testing.PermissionControl
23
+ import __PACKAGE__.testing.ProcessControl
24
+ import __PACKAGE__.testing.Shell
25
+ import __PACKAGE__.testing.TimeWarp
26
+ import java.util.concurrent.CountDownLatch
27
+ import java.util.concurrent.TimeUnit
28
+ import org.junit.After
29
+ import org.junit.Assert.assertEquals
30
+ import org.junit.Assert.assertNotEquals
31
+ import org.junit.Assert.assertTrue
32
+ import org.junit.Assume.assumeTrue
33
+ import org.junit.Test
34
+ import org.junit.runner.RunWith
35
+
36
+ /**
37
+ * The exemplars for RUNTIME STATE CONTROL — the seam's second organ family.
38
+ *
39
+ * One principle: a claim is provable when the test can put the system into the state the
40
+ * claim is about. Most unprovable mobile claims are unprovable for exactly that reason —
41
+ * the state is hard to reach. You'd have to wait hours for Doze, ship to a user who
42
+ * denies the permission, or hope the OS reclaims your process while you watch. The
43
+ * organs in `testing/` reach those states on demand and restore them in `finally`:
44
+ * [TimeWarp] (the clock), [DozeControl] (device idle), [PermissionControl] (grants),
45
+ * [ProcessControl] (activity reclaim), NetworkControl (offline), ConfigControl (dark
46
+ * mode / font scale / locale). Emulator-only by guard, root-free by construction, and
47
+ * composable — the flagship below nests two of them.
48
+ *
49
+ * These tests assert universal facts through the app's real process, like
50
+ * [PlatformBehaviorSeamTest] does: the stamped app has no alarm or notification feature
51
+ * yet, so the arrange steps talk to the OS directly. When your feature exists, its
52
+ * behavior test replaces the arrange step with YOUR scheduling/posting/restoring path
53
+ * and keeps the shape — state in, act, observe, state restored.
54
+ *
55
+ * Runs via `:composeApp:connectedDebugAndroidTest` — the verify lane's `androidChecks`
56
+ * step (SKIPs when no device is attached).
57
+ */
58
+ @RunWith(AndroidJUnit4::class)
59
+ class RuntimeStateSeamTest {
60
+
61
+ private val context: Context
62
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
63
+
64
+ private val notificationManager: NotificationManager
65
+ get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
66
+
67
+ private val alarmManager: AlarmManager
68
+ get() = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
69
+
70
+ @After
71
+ fun tearDown() {
72
+ // Leave the device as found — real OS state was asserted, so real OS state is
73
+ // cleaned up (the organs restore their own brackets; this catches the probes).
74
+ notificationManager.cancelAll()
75
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
76
+ notificationManager.deleteNotificationChannel(PROBE_CHANNEL)
77
+ }
78
+ alarmManager.cancel(probePendingIntent())
79
+ }
80
+
81
+ @Test
82
+ fun anExactAllowWhileIdleAlarmDeliversFromInsideForcedDeepIdle() {
83
+ // THE FLAGSHIP. `setExactAndAllowWhileIdle` exists entirely to survive Doze, and
84
+ // that half of its name had never been verified: registration was proven
85
+ // (AlarmAsserts), delivery-on-an-awake-device was proven (TimeWarp), but
86
+ // delivery FROM INSIDE the idle state — the production claim, the #1 escaped-bug
87
+ // class in real apps on this template — required a device nobody was willing to
88
+ // leave motionless on battery for an hour. Composed organs make it a
89
+ // sixty-second test: force deep idle, warp the clock past the trigger, and the
90
+ // OS either honors the API's promise or the test says it didn't. In your
91
+ // feature's test, the arrange step becomes YOUR scheduling ladder; keep the
92
+ // shape: schedule → assert registered → force idle → warp → await delivery.
93
+ TimeWarp.assumeOnEmulator()
94
+
95
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
96
+ assertTrue(
97
+ "canScheduleExactAlarms() is false — the debug-manifest exact-alarm " +
98
+ "grant (androidDebug/AndroidManifest.xml) is missing; " +
99
+ "setExactAndAllowWhileIdle would throw SecurityException",
100
+ alarmManager.canScheduleExactAlarms(),
101
+ )
102
+ }
103
+
104
+ val delivered = CountDownLatch(1)
105
+ val receiver = object : BroadcastReceiver() {
106
+ override fun onReceive(receiverContext: Context?, intent: Intent?) {
107
+ delivered.countDown()
108
+ }
109
+ }
110
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
111
+ context.registerReceiver(
112
+ receiver,
113
+ IntentFilter(ACTION_DOZE_PROBE),
114
+ Context.RECEIVER_NOT_EXPORTED,
115
+ )
116
+ } else {
117
+ @Suppress("UnspecifiedRegisterReceiverFlag")
118
+ context.registerReceiver(receiver, IntentFilter(ACTION_DOZE_PROBE))
119
+ }
120
+
121
+ try {
122
+ val target = System.currentTimeMillis() + 2 * 60_000L
123
+ alarmManager.setExactAndAllowWhileIdle(
124
+ AlarmManager.RTC_WAKEUP,
125
+ target,
126
+ probePendingIntent(),
127
+ )
128
+ AlarmAsserts.assertAlarmRegistered("the doze-probe exact alarm") {
129
+ it.whenMs == target
130
+ }
131
+
132
+ // Doze outside, clock inside: the device is idle FIRST, then its trigger
133
+ // time arrives — the same order the world runs in.
134
+ DozeControl.withDeviceIdle(DozeControl.IdleMode.DEEP) {
135
+ TimeWarp.withWarpedClock(target + 5_000L) {
136
+ assertTrue(
137
+ "the exact allow-while-idle alarm did not deliver within 60s of " +
138
+ "its trigger time arriving inside forced deep idle — the one " +
139
+ "job the API's name promises",
140
+ delivered.await(60, TimeUnit.SECONDS),
141
+ )
142
+ }
143
+ }
144
+
145
+ // Delivery consumes the OS slot (bounded poll; the table can trail the
146
+ // broadcast slightly).
147
+ val goneDeadline = SystemClock.elapsedRealtime() + 10_000L
148
+ while (SystemClock.elapsedRealtime() < goneDeadline &&
149
+ AlarmAsserts.registeredAlarms().any { it.whenMs == target }
150
+ ) {
151
+ Thread.sleep(200)
152
+ }
153
+ assertTrue(
154
+ "the doze-probe alarm fired but is still in the OS alarm table — a " +
155
+ "fired one-shot must be consumed, not re-armed",
156
+ AlarmAsserts.registeredAlarms().none { it.whenMs == target },
157
+ )
158
+ } finally {
159
+ context.unregisterReceiver(receiver)
160
+ }
161
+ }
162
+
163
+ @Test
164
+ fun aNotificationPostedWithoutTheGrantNeverReachesTheShade() {
165
+ // The permission-denied exemplar: the fresh-install default on API 33+ is
166
+ // DENIED, and in that state the OS drops every post silently — code runs, phone
167
+ // stays dark, no error anywhere. This proves that drop mechanically: post under
168
+ // denial, then hold assertNoNotification's full window. The bracket SKIPs when
169
+ // the grant is already held (the seam's other suite takes it, and un-granting
170
+ // in-process would kill this test — PermissionControl's header carries the
171
+ // whole trap). In your feature's test, the act step becomes YOUR notification
172
+ // path, and the assertion becomes "it degrades the way the spec says" —
173
+ // re-prompt, in-app banner, queued — instead of "nothing happened".
174
+ // The permission only exists — and only gates posting — from API 33; below that
175
+ // a post lands regardless, so there is no denied state to prove.
176
+ assumeTrue(
177
+ "POST_NOTIFICATIONS gates posting only from API 33 " +
178
+ "(device is ${Build.VERSION.SDK_INT})",
179
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
180
+ )
181
+ Shell.assumeOnEmulator(
182
+ "this exemplar reads and posts against the real shade in a denied state — " +
183
+ "stock QA AVDs only",
184
+ )
185
+ PermissionControl.withPermissionDenied("android.permission.POST_NOTIFICATIONS") {
186
+ assertTrue(
187
+ "areNotificationsEnabled() is true while POST_NOTIFICATIONS is denied — " +
188
+ "the two surfaces should agree on API 33+",
189
+ !notificationManager.areNotificationsEnabled(),
190
+ )
191
+ notificationManager.createNotificationChannel(
192
+ NotificationChannel(
193
+ PROBE_CHANNEL,
194
+ "Runtime-state probe",
195
+ NotificationManager.IMPORTANCE_HIGH,
196
+ ),
197
+ )
198
+ notificationManager.notify(
199
+ PROBE_ID,
200
+ Notification.Builder(context, PROBE_CHANNEL)
201
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
202
+ .setContentTitle("runtime-state probe")
203
+ .build(),
204
+ )
205
+ NotificationAsserts.assertNoNotification { it.id == PROBE_ID }
206
+ }
207
+ }
208
+
209
+ @Test
210
+ fun theOsRebuildsTheActivityThroughTheSavedStatePathWhenItReclaimsIt() {
211
+ // The process-death exemplar — with the seam's honesty about what "process
212
+ // death" can mean in-process. Instrumentation lives in the app's process, and
213
+ // the OS pins that process at foreground importance, so `am kill` cannot
214
+ // reclaim it (asserted LIVE below, so the claim never rots into a stale
215
+ // comment). What the OS genuinely destroys and rebuilds is the ACTIVITY:
216
+ // under don't-keep-activities, backgrounding destroys it for real —
217
+ // ViewModelStore cleared, non-config instances dropped — and the return trip
218
+ // rebuilds it from saved-instance state, the same path a true process death
219
+ // takes at the activity layer. In your feature's test, the assertions become
220
+ // YOUR restored state: the half-typed form, the scroll position, the selection.
221
+ ProcessControl.withDontKeepActivities {
222
+ val first = ProcessControl.relaunchApp()
223
+ val firstIdentity = System.identityHashCode(first)
224
+
225
+ // The saved-state probe: a nonce registered into the FIRST instance's
226
+ // SavedStateRegistry (MainActivity is a ComponentActivity, and everything
227
+ // rememberSaveable/SavedStateHandle keeps rides this same registry). If the
228
+ // rebuild really runs through the saved-instance-state path, the SECOND
229
+ // instance restores it — asserted below, so "through the saved-state path"
230
+ // is observed, not inferred from a new instance existing.
231
+ assertTrue(
232
+ "the launcher activity is not a SavedStateRegistryOwner — the " +
233
+ "template's MainActivity is a ComponentActivity, which is one; " +
234
+ "the saved-state probe needs that registry",
235
+ first is SavedStateRegistryOwner,
236
+ )
237
+ val savedProbe = "probe-${SystemClock.elapsedRealtimeNanos()}"
238
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
239
+ (first as SavedStateRegistryOwner).savedStateRegistry
240
+ .registerSavedStateProvider(SAVED_STATE_PROBE_KEY) {
241
+ Bundle().apply { putString("token", savedProbe) }
242
+ }
243
+ }
244
+
245
+ ProcessControl.backgroundApp()
246
+ // Backgrounding and destruction are separate asynchronous steps; relaunching
247
+ // between them would resume the SAME instance and prove nothing.
248
+ ProcessControl.awaitDestroyed(first)
249
+
250
+ // The live no-op proof: am kill against the pinned instrumented process
251
+ // must leave this very pid running (observed from the OS side, not from
252
+ // hope). If an OS change ever breaks the pinning, this line is where the
253
+ // suite says so.
254
+ ProcessControl.killReclaimableProcesses()
255
+ val pids = Shell.exec("pidof ${context.packageName}").trim()
256
+ .split(Regex("\\s+")).filter { it.isNotEmpty() }
257
+ assertTrue(
258
+ "am kill reclaimed the instrumented process (pid ${Process.myPid()} " +
259
+ "not in '$pids') — the foreground pinning assumption no longer " +
260
+ "holds on this image; ProcessControl's header needs revisiting",
261
+ pids.contains(Process.myPid().toString()),
262
+ )
263
+
264
+ val second = ProcessControl.relaunchApp()
265
+ try {
266
+ assertNotEquals(
267
+ "the same Activity instance came back after backgrounding under " +
268
+ "don't-keep-activities — nothing was destroyed, so nothing " +
269
+ "about state restoration was proven",
270
+ firstIdentity,
271
+ System.identityHashCode(second),
272
+ )
273
+ var restored: Bundle? = null
274
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
275
+ restored = (second as SavedStateRegistryOwner).savedStateRegistry
276
+ .consumeRestoredStateForKey(SAVED_STATE_PROBE_KEY)
277
+ }
278
+ assertEquals(
279
+ "a NEW instance resumed but the probe did not come back through " +
280
+ "its SavedStateRegistry — destruction happened, restoration " +
281
+ "did not, so the saved-state path is NOT proven",
282
+ savedProbe,
283
+ restored?.getString("token"),
284
+ )
285
+ } finally {
286
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
287
+ second.finish()
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ private fun probePendingIntent(): PendingIntent =
294
+ PendingIntent.getBroadcast(
295
+ context,
296
+ REQUEST_DOZE_PROBE,
297
+ Intent(ACTION_DOZE_PROBE).setPackage(context.packageName),
298
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
299
+ )
300
+
301
+ private companion object {
302
+ const val PROBE_CHANNEL = "runtime_state_probe"
303
+ const val PROBE_ID = 434_242
304
+ const val ACTION_DOZE_PROBE = "cmp.seam.DOZE_PROBE_ALARM"
305
+ const val REQUEST_DOZE_PROBE = 434_301
306
+ const val SAVED_STATE_PROBE_KEY = "cmp.seam.savedStateProbe"
307
+ }
308
+ }