create-cmp-cli 0.10.1 → 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 (52) 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/lib/tabs.mjs +26 -0
  5. package/src/scaffold.mjs +7 -2
  6. package/template/.claude/settings.json +30 -0
  7. package/template/.claude/skills/add-feature/SKILL.md +20 -0
  8. package/template/.claude/skills/add-repository/SKILL.md +6 -0
  9. package/template/.claude/skills/add-screen/SKILL.md +6 -0
  10. package/template/CLAUDE.md +129 -8
  11. package/template/composeApp/build.gradle.kts +69 -0
  12. package/template/composeApp/proguard-rules.pro +12 -0
  13. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  25. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
  32. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  33. package/template/docs/ARCHITECTURE.md +41 -2
  34. package/template/docs/TESTING.md +165 -0
  35. package/template/gradle/libs.versions.toml +15 -0
  36. package/template/manifest.json +1 -0
  37. package/template/qa/approve.mjs +119 -11
  38. package/template/qa/evidence/schema.json +20 -2
  39. package/template/qa/lib/affected-tests.mjs +147 -0
  40. package/template/qa/lib/approvals.mjs +602 -21
  41. package/template/qa/lib/device-lease.mjs +249 -0
  42. package/template/qa/lib/evidence-level.mjs +117 -0
  43. package/template/qa/lib/feature-brief.mjs +324 -0
  44. package/template/qa/lib/inputs-hash.mjs +43 -6
  45. package/template/qa/lib/reachability.mjs +211 -0
  46. package/template/qa/lib/spec-coverage.mjs +131 -0
  47. package/template/qa/lib/step-cache.mjs +221 -0
  48. package/template/qa/receipt-check.mjs +22 -2
  49. package/template/qa/scaffold-feature.mjs +20 -1
  50. package/template/qa/verify.mjs +776 -95
  51. package/template/qa/watch.mjs +622 -0
  52. package/template/specs/app-base.spec.md +11 -0
@@ -0,0 +1,152 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import androidx.test.platform.app.InstrumentationRegistry
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertTrue
6
+
7
+ /**
8
+ * Alarm assertions against the OS alarm table (`dumpsys alarm`) — what AlarmManager
9
+ * actually HOLDS for this app, not what the app believes it scheduled.
10
+ *
11
+ * Why this exists — the PendingIntent identity trap: AlarmManager keys alarms by
12
+ * PendingIntent identity (creator + request code + intent filterEquals — extras excluded).
13
+ * Two logically distinct alarms built with the same request code and an intent differing
14
+ * only in extras silently collapse into ONE slot: the second `set()` replaces the first,
15
+ * no error, no log line, and every JVM tier stays green. The only place the collapse is
16
+ * visible is the OS alarm table, which is what this file reads.
17
+ *
18
+ * Constraints the code can't show:
19
+ * - `dumpsys alarm` is a human-oriented dump whose exact format shifts across API levels
20
+ * and OEMs. The parser below is deliberately tolerant — it anchors on the stable parts
21
+ * (`Alarm{... this-package}` entry lines, the indented `tag=` detail) and carries the
22
+ * raw block so a predicate can always fall back to substring matching. If a future
23
+ * Android release reshuffles the dump, fix the parser here, once — tests state intent
24
+ * through predicates, not format.
25
+ * - Reading the table proves REGISTRATION, not delivery. Delivery is its own proof:
26
+ * [TimeWarp] warps the clock past the trigger, and [DozeControl] holds the device in
27
+ * forced idle while it happens. App-standby buckets and OEM battery managers remain
28
+ * outside the seam's reach (see docs/TESTING.md).
29
+ * - The shell runs with the instrumentation's uiAutomation (shell uid, via [Shell.exec]),
30
+ * so no permission or root is needed.
31
+ */
32
+ object AlarmAsserts {
33
+
34
+ /**
35
+ * One row of the OS alarm table attributed to [packageName].
36
+ *
37
+ * @property whenMs the alarm's trigger wall/elapsed time in ms as printed by the dump
38
+ * (`when` field), or null when the entry line carries no parseable `when`.
39
+ * @property tag the `tag=` detail line if present (AlarmManager's listener/operation
40
+ * tag, e.g. `*walarm*:pkg/receiver`), else "".
41
+ * @property raw the full dump block for this entry — the fallback surface for
42
+ * predicates when the parsed fields aren't enough.
43
+ */
44
+ data class RegisteredAlarm(val whenMs: Long?, val tag: String, val raw: String)
45
+
46
+ /** All alarms the OS currently holds for [packageName] (default: the app under test). */
47
+ fun registeredAlarms(
48
+ packageName: String =
49
+ InstrumentationRegistry.getInstrumentation().targetContext.packageName,
50
+ ): List<RegisteredAlarm> = parseDumpsysAlarm(Shell.exec("dumpsys alarm"), packageName)
51
+
52
+ /**
53
+ * Asserts at least one alarm matching [predicate] is registered. [what] names the
54
+ * expectation in the failure message ("the daily-reset alarm").
55
+ */
56
+ fun assertAlarmRegistered(
57
+ what: String = "a matching alarm",
58
+ packageName: String =
59
+ InstrumentationRegistry.getInstrumentation().targetContext.packageName,
60
+ predicate: (RegisteredAlarm) -> Boolean,
61
+ ) {
62
+ val alarms = registeredAlarms(packageName)
63
+ assertTrue(
64
+ "$what is not in the OS alarm table for $packageName. " +
65
+ "Registered: ${describe(alarms)}",
66
+ alarms.any(predicate),
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Asserts EXACTLY [expected] matching alarms are registered — the generic form of the
72
+ * PendingIntent-identity-collision bug. If two logical alarms share one PendingIntent
73
+ * slot, the table holds one entry and this fails with the survivors listed; schedule
74
+ * your N alarms, then assert N distinct rows.
75
+ */
76
+ fun assertDistinctAlarms(
77
+ expected: Int,
78
+ what: String = "matching alarms",
79
+ packageName: String =
80
+ InstrumentationRegistry.getInstrumentation().targetContext.packageName,
81
+ predicate: (RegisteredAlarm) -> Boolean,
82
+ ) {
83
+ val matching = registeredAlarms(packageName).filter(predicate)
84
+ assertEquals(
85
+ "expected $expected distinct $what in the OS alarm table but found " +
86
+ "${matching.size} — fewer than scheduled usually means two logical alarms " +
87
+ "share one PendingIntent identity (same request code + filter-equal intent) " +
88
+ "and the later set() replaced the earlier. Found: ${describe(matching)}",
89
+ expected,
90
+ matching.size,
91
+ )
92
+ }
93
+
94
+ // ── dump plumbing ───────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Tolerant `dumpsys alarm` parse: an entry starts at a line containing
98
+ * `Alarm{... <packageName>` (batched and unbatched dumps both print this shape) and
99
+ * runs until the next entry line or an outdent. `when <millis>` is read off the entry
100
+ * line when present; the first indented `tag=` detail line is captured.
101
+ */
102
+ internal fun parseDumpsysAlarm(dump: String, packageName: String): List<RegisteredAlarm> {
103
+ val entryRe = Regex("""Alarm\{[^}]*\b${Regex.escape(packageName)}\b[^}]*\}""")
104
+ // Entry-line trigger time. Two shapes across API levels: legacy prints
105
+ // `when <ms>`, modern (S+) prints `origWhen <ms> whenElapsed <ms>` — origWhen is
106
+ // the requested wall/elapsed time, which is what a test scheduled and can equate.
107
+ // Digits only, deliberately: the DETAIL lines print `when=+56m6s0ms` (a formatted
108
+ // duration), and a looser pattern would swallow that "+56" as milliseconds.
109
+ val whenRe = Regex("""\b(?:origWhen|when)\b[ =]+(\d+)""")
110
+ val tagRe = Regex("""\btag=(\S+)""")
111
+
112
+ val lines = dump.lines()
113
+ val alarms = mutableListOf<RegisteredAlarm>()
114
+ var i = 0
115
+ while (i < lines.size) {
116
+ val line = lines[i]
117
+ if (entryRe.containsMatchIn(line)) {
118
+ val entryIndent = line.indexOfFirst { !it.isWhitespace() }.coerceAtLeast(0)
119
+ val block = StringBuilder(line)
120
+ var tag = tagRe.find(line)?.groupValues?.get(1) ?: ""
121
+ var j = i + 1
122
+ while (j < lines.size) {
123
+ val next = lines[j]
124
+ val nextIndent = next.indexOfFirst { !it.isWhitespace() }
125
+ // Stop at the next entry, a blank line, or an outdent back to/above
126
+ // this entry's level — the detail lines of an entry are indented past it.
127
+ if (next.isBlank() || entryRe.containsMatchIn(next) ||
128
+ (nextIndent in 0..entryIndent)
129
+ ) break
130
+ block.append('\n').append(next)
131
+ if (tag.isEmpty()) tag = tagRe.find(next)?.groupValues?.get(1) ?: ""
132
+ j++
133
+ }
134
+ alarms.add(
135
+ RegisteredAlarm(
136
+ whenMs = whenRe.find(line)?.groupValues?.get(1)?.toLongOrNull(),
137
+ tag = tag,
138
+ raw = block.toString(),
139
+ ),
140
+ )
141
+ i = j
142
+ } else {
143
+ i++
144
+ }
145
+ }
146
+ return alarms
147
+ }
148
+
149
+ private fun describe(alarms: List<RegisteredAlarm>): String =
150
+ if (alarms.isEmpty()) "(none)"
151
+ else alarms.joinToString { "[when=${it.whenMs} tag=${it.tag}]" }
152
+ }
@@ -0,0 +1,124 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.os.Build
4
+ import androidx.test.platform.app.InstrumentationRegistry
5
+ import org.junit.Assume.assumeTrue
6
+
7
+ /**
8
+ * Configuration control for the on-device tier — dark mode, font scale, and per-app
9
+ * locale as test inputs.
10
+ *
11
+ * Why this exists: configuration is the OTHER classic state-loss lever. Every one of
12
+ * these switches delivers a configuration change, and a configuration change destroys
13
+ * and recreates the foreground activity — the same "came back and it was gone" class
14
+ * [ProcessControl] targets, triggered by the user flipping dark mode or bumping the
15
+ * system font size mid-session. The JVM tiers render one configuration forever; a claim
16
+ * like "the form survives the user toggling dark mode" or "the layout holds at 1.3x
17
+ * font scale" needs the device to actually change underneath the running app. Compose
18
+ * with the seam's observation helpers: flip the config, then assert what the app did
19
+ * about it.
20
+ *
21
+ * Mechanisms, each verified root-free from the shell uid on a stock user-build emulator
22
+ * image (API 35), each snapshot-restored in `finally`:
23
+ * - [withDarkMode] — `cmd uimode night yes|no` (read back via the same command's
24
+ * "Night mode: X" line, so restore is snapshot-exact, including `auto` and the
25
+ * custom modes).
26
+ * - [withFontScale] — `settings put system font_scale <x>`; an absent prior value is
27
+ * DELETED on restore, not defaulted to 1.0 (see [Shell.restoreSetting]).
28
+ * - [withAppLocale] — `cmd locale set-app-locales <pkg> --locales <tags>`, the per-app
29
+ * locale system (API 33+; the bracket SKIPs below that). App-scoped on purpose: it
30
+ * is the same state the user's own per-app language setting writes, and it touches
31
+ * no other app on the device. Restore passes the snapshotted list back, or clears by
32
+ * omitting `--locales` (the documented "empty when unspecified").
33
+ *
34
+ * Constraints the code can't show:
35
+ * - DEVICE-WIDE locale has no root-free path — changing it needs
36
+ * CHANGE_CONFIGURATION/root, so this organ does not offer it rather than offering a
37
+ * broken version of it. For "the whole device is German", set the AVD up that way;
38
+ * for "this app renders German", [withAppLocale] is the real user-reachable state.
39
+ * - Config delivery is asynchronous and, for a foreground activity, DESTRUCTIVE — the
40
+ * activity you held a reference to before the flip is not the one resumed after it.
41
+ * That is the point, not a flake: capture identity before, await the recreated
42
+ * activity ([ProcessControl.resumedActivity]), then assert.
43
+ * - What the app DOES with the change (re-render, reload resources, lose the form) is
44
+ * the app's half; these brackets only guarantee the device half moved.
45
+ */
46
+ object ConfigControl {
47
+
48
+ private val context
49
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
50
+
51
+ /**
52
+ * Run [block] with night mode forced on (`cmd uimode night yes`), restoring the
53
+ * snapshotted mode afterwards even when the block throws.
54
+ */
55
+ fun <T> withDarkMode(block: () -> T): T {
56
+ assumeEmulator("Forcing dark mode")
57
+ val prior = readNightMode()
58
+ Shell.exec("cmd uimode night yes")
59
+ try {
60
+ return block()
61
+ } finally {
62
+ Shell.exec("cmd uimode night $prior")
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Run [block] with the system font scale at [scale] (e.g. 1.3f — the accessibility
68
+ * sizes users actually run), restoring the prior value (or its absence) afterwards.
69
+ */
70
+ fun <T> withFontScale(scale: Float, block: () -> T): T {
71
+ assumeEmulator("Changing the font scale")
72
+ val prior = Shell.readSetting("system", "font_scale")
73
+ Shell.exec("settings put system font_scale $scale")
74
+ try {
75
+ return block()
76
+ } finally {
77
+ Shell.restoreSetting("system", "font_scale", prior)
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Run [block] with the app under test set to [languageTag] (BCP-47, e.g. "fr-FR"),
83
+ * via the per-app locale system — API 33+; SKIPs (assumption) below that, never
84
+ * vacuously green. Restores the snapshotted locale list afterwards.
85
+ */
86
+ fun <T> withAppLocale(languageTag: String, block: () -> T): T {
87
+ assumeEmulator("Changing the app locale")
88
+ assumeTrue(
89
+ "per-app locales need API 33+ (device is ${Build.VERSION.SDK_INT}); there " +
90
+ "is no root-free device-wide locale switch to fall back to — configure " +
91
+ "the AVD's locale instead for older images",
92
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU,
93
+ )
94
+ val prior = readAppLocales() // comma-separated tags, or "" when following system
95
+ Shell.exec("cmd locale set-app-locales ${context.packageName} --locales $languageTag")
96
+ try {
97
+ return block()
98
+ } finally {
99
+ if (prior.isEmpty()) {
100
+ // Omitting --locales is the documented "empty list", i.e. follow system.
101
+ Shell.exec("cmd locale set-app-locales ${context.packageName}")
102
+ } else {
103
+ Shell.exec("cmd locale set-app-locales ${context.packageName} --locales $prior")
104
+ }
105
+ }
106
+ }
107
+
108
+ /** The current night mode word as `cmd uimode night` prints it (`yes`/`no`/`auto`/custom). */
109
+ private fun readNightMode(): String =
110
+ Shell.exec("cmd uimode night").substringAfter("Night mode:").trim().ifEmpty { "no" }
111
+
112
+ /** The app's current locale list ("fr-FR,en" style), or "" when it follows the system. */
113
+ private fun readAppLocales(): String =
114
+ Shell.exec("cmd locale get-app-locales ${context.packageName}")
115
+ .substringAfter("are [", "").substringBefore("]").trim()
116
+
117
+ private fun assumeEmulator(what: String) {
118
+ Shell.assumeOnEmulator(
119
+ "ConfigControl runs only on emulators (ro.kernel.qemu != 1 here). $what on " +
120
+ "a real phone rewrites its owner's chosen settings — run this suite on " +
121
+ "a stock QA AVD instead.",
122
+ )
123
+ }
124
+ }
@@ -0,0 +1,113 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.os.SystemClock
4
+ import org.junit.Assert.assertEquals
5
+
6
+ /**
7
+ * Doze (device idle) control for the on-device tier — put the device INTO the state the
8
+ * claim is about, instead of shipping the claim and waiting for the field to test it.
9
+ *
10
+ * Why this exists: Doze is the #1 production cause of "the alarm never fired". A device
11
+ * that sits still on battery drops into deep idle, and in that state regular alarms are
12
+ * deferred to the next maintenance window — hours away — while `setExactAndAllowWhileIdle`
13
+ * exists ENTIRELY to punch through it. That API's whole value is its behavior in a state
14
+ * no test tier ever visited: the JVM tiers can't see the OS, [AlarmAsserts] proves the OS
15
+ * holds the alarm, [TimeWarp] proves delivery on an awake device — and "delivers from
16
+ * inside Doze", the actual production claim, shipped unverified every time. This organ
17
+ * closes that: force the idle state, then let TimeWarp warp past the trigger, and the
18
+ * delivery-from-Doze claim becomes a sixty-second test (see RuntimeStateSeamTest for the
19
+ * composed exemplar).
20
+ *
21
+ * The sequence, each command verified root-free from the shell uid on a stock user-build
22
+ * emulator image (API 35):
23
+ *
24
+ * 1. `dumpsys battery unplug` — fake "on battery". Real Doze never engages on charge;
25
+ * modern `force-idle` no longer strictly requires this, but older API levels did and
26
+ * the precondition is part of the state being modeled, so it stays.
27
+ * 2. `dumpsys deviceidle force-idle deep|light` — teleport straight into the idle
28
+ * state ("Now forced in to deep idle mode"). Verified with `deviceidle get` — the
29
+ * command's own output is not trusted.
30
+ * 3. the block — schedule/warp/assert.
31
+ * 4. finally: `dumpsys deviceidle unforce` + `dumpsys battery reset`, then a bounded
32
+ * poll until the controller reports ACTIVE again. A test that leaves the device
33
+ * idle poisons every later test, so the restore runs even when the block throws.
34
+ *
35
+ * HONESTY — what forced idle does and does not reproduce. Overclaiming here would be
36
+ * worse than the gap, so read this before citing a green test as proof:
37
+ *
38
+ * REPRODUCED faithfully:
39
+ * - The idle STATE itself, as AlarmManager sees it: regular alarms deferred,
40
+ * `setAndAllowWhileIdle`/`setExactAndAllowWhileIdle` eligible to fire (throttled to
41
+ * one window per app per ~9 minutes — one alarm per test is inside the budget).
42
+ * This is the alarm-delivery policy, keyed on the DEVICE state, and it is exactly
43
+ * the claim the composed exemplar proves.
44
+ * - Light vs deep as distinct states (`IdleMode`): light restricts jobs/syncs; deep
45
+ * is the full alarm-deferral regime. An exact-alarm claim is about DEEP.
46
+ *
47
+ * NOT reproduced:
48
+ * - The path INTO idle. `force-idle` skips the real ladder (screen-off, stationary,
49
+ * IDLE_PENDING → SENSING → LOCATING → IDLE), so motion-exit and the timing of the
50
+ * descent are untested here.
51
+ * - Maintenance windows. Forced idle HOLDS the state; real deep Doze alternates
52
+ * IDLE ↔ IDLE_MAINTENANCE. "My deferred work runs in the maintenance window" is a
53
+ * different claim and this organ does not prove it.
54
+ * - App-standby buckets. A separate throttling system (`am set-standby-bucket`),
55
+ * untouched by this organ.
56
+ * - The cached-process experience. Instrumentation pins the app's process at
57
+ * foreground oom-adj, so the network-cutoff and job-suspension that a CACHED process
58
+ * feels in deep Doze do not bite the app under test. The OS-side alarm policy above
59
+ * is unaffected by that pinning — which is why the alarm claim survives this caveat
60
+ * and a "my background sync pauses in Doze" claim does not.
61
+ * - OEM battery managers (aggressive kill lists, vendor "optimization"). Emulator-only
62
+ * by construction; the OEM half stays a documented manual tier.
63
+ */
64
+ object DozeControl {
65
+
66
+ /** The two real idle regimes. An exact-alarm claim is about [DEEP]. */
67
+ enum class IdleMode(internal val arg: String) { LIGHT("light"), DEEP("deep") }
68
+
69
+ /**
70
+ * Run [block] with the device forced into [mode] idle, restoring everything
71
+ * afterwards even when the block throws. Entry is VERIFIED (`deviceidle get` must
72
+ * report IDLE) before the block runs — a bracket that silently failed to enter the
73
+ * state would hand out green for a claim it never tested.
74
+ *
75
+ * Composes with the other organs by nesting; the canonical composition is
76
+ * Doze outside, clock inside:
77
+ *
78
+ * DozeControl.withDeviceIdle {
79
+ * TimeWarp.withWarpedClock(target + epsilon) { awaitDelivery() }
80
+ * }
81
+ */
82
+ fun <T> withDeviceIdle(mode: IdleMode = IdleMode.DEEP, block: () -> T): T {
83
+ Shell.assumeOnEmulator(
84
+ "DozeControl runs only on emulators (ro.kernel.qemu != 1 here). Forcing a " +
85
+ "real phone into idle silences its owner's real alarms and messages — " +
86
+ "run this suite on a stock QA AVD instead.",
87
+ )
88
+ Shell.exec("dumpsys battery unplug")
89
+ Shell.exec("dumpsys deviceidle force-idle ${mode.arg}")
90
+ try {
91
+ assertEquals(
92
+ "the device did not enter forced ${mode.arg} idle — without the state, " +
93
+ "nothing this block asserts is about Doze. On a stock emulator this " +
94
+ "sequence is verified to work; a changed image or API may need " +
95
+ "`dumpsys deviceidle help` re-read.",
96
+ "IDLE",
97
+ Shell.exec("dumpsys deviceidle get ${mode.arg}").trim(),
98
+ )
99
+ return block()
100
+ } finally {
101
+ Shell.exec("dumpsys deviceidle unforce")
102
+ Shell.exec("dumpsys battery reset")
103
+ // Bounded settle: on the verified image `unforce` returns to ACTIVE
104
+ // immediately, but the next test deserves the wait, not the race.
105
+ val deadline = SystemClock.elapsedRealtime() + 5_000L
106
+ while (SystemClock.elapsedRealtime() < deadline &&
107
+ Shell.exec("dumpsys deviceidle get ${mode.arg}").trim() != "ACTIVE"
108
+ ) {
109
+ Thread.sleep(100)
110
+ }
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,137 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import android.net.NetworkCapabilities
6
+ import android.os.SystemClock
7
+ import androidx.test.platform.app.InstrumentationRegistry
8
+ import org.junit.Assert.fail
9
+
10
+ /**
11
+ * Network-state control for the on-device tier — make "offline" a state the test puts
12
+ * the device into, instead of a branch the code claims to handle.
13
+ *
14
+ * Why this exists: the template ships a connectivity abstraction
15
+ * (`core/connectivity/NetworkMonitor.kt`, expect/actual) whose entire reason to exist is
16
+ * behavior under network loss — and no tier could ever produce network loss, so every
17
+ * claim built on it (offline banners, retry paths, queued writes) was structurally
18
+ * unprovable. Desktop fakes prove the ViewModel folds a Boolean; only the device can
19
+ * prove the Boolean tracks the world.
20
+ *
21
+ * Mechanisms, each verified root-free from the shell uid on a stock user-build emulator
22
+ * image (API 35):
23
+ * - [withAirplaneMode] — `cmd connectivity airplane-mode enable|disable`, the modern,
24
+ * reliable, root-free switch (verified: active default network gone on enable, back
25
+ * on disable; the same command queries the state, so restore is snapshot-exact).
26
+ * The LEGACY route — `settings put global airplane_mode_on` plus a broadcast — is NOT
27
+ * usable root-free on modern API levels: the broadcast is protected, and the setting
28
+ * alone changes nothing. Prefer this bracket for "the app is offline".
29
+ * - [withWifiDisabled] / [withMobileDataDisabled] — `svc wifi|data disable|enable`,
30
+ * for claims about ONE transport (e.g. metered-only behavior, wifi-to-cellular
31
+ * failover). Prior state is snapshotted from `settings get global wifi_on` /
32
+ * `mobile_data` (both verified to track the toggles) so restore matches what was.
33
+ *
34
+ * Constraints the code can't show:
35
+ * - State changes are ASYNCHRONOUS. Each bracket polls ConnectivityManager (bounded)
36
+ * until the state actually lands before running the block — asserting "offline
37
+ * behavior" while the network is still up tests nothing — and polls the way back on
38
+ * exit, because a device left offline poisons every later test (the restore runs in
39
+ * `finally` either way; if connectivity does not return in time the bracket fails
40
+ * loudly rather than leaving a silent trap).
41
+ * - The emulator's adb transport rides the qemu pipe, not the device's network — so
42
+ * airplane mode cannot sever the harness from the device. On a REAL device attached
43
+ * over wifi-adb it would, which is one more reason these brackets are emulator-only.
44
+ * - Emulator networking is NAT through the host: "wifi" and "cellular" here are both
45
+ * the host's connection wearing different transports. Presence/absence and transport
46
+ * switching are faithful; bandwidth, latency, captive portals, and flaky-RSSI
47
+ * behavior are not reproduced.
48
+ */
49
+ object NetworkControl {
50
+
51
+ private val connectivity: ConnectivityManager
52
+ get() = InstrumentationRegistry.getInstrumentation().targetContext
53
+ .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
54
+
55
+ private const val SETTLE_TIMEOUT_MS = 15_000L
56
+
57
+ /**
58
+ * Run [block] with the device fully offline (airplane mode), restoring the prior
59
+ * airplane state afterwards even when the block throws. The block runs only after
60
+ * ConnectivityManager reports no active network; on exit the bracket waits for
61
+ * connectivity to return and fails loudly if it does not.
62
+ */
63
+ fun <T> withAirplaneMode(block: () -> T): T {
64
+ assumeEmulator("Airplane mode")
65
+ val prior = Shell.exec("cmd connectivity airplane-mode").trim() // enabled|disabled
66
+ Shell.exec("cmd connectivity airplane-mode enable")
67
+ try {
68
+ awaitState("no active network (airplane mode)") { connectivity.activeNetwork == null }
69
+ return block()
70
+ } finally {
71
+ Shell.exec("cmd connectivity airplane-mode ${if (prior == "enabled") "enable" else "disable"}")
72
+ if (prior != "enabled") {
73
+ awaitState("connectivity restored after airplane mode") {
74
+ connectivity.activeNetwork != null
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Run [block] with wifi off (`svc wifi disable`), restoring the snapshotted state.
82
+ * The block runs once no active network carries TRANSPORT_WIFI — on an emulator with
83
+ * cellular up, the default network fails over to it, which is itself testable
84
+ * behavior (assert what [block] sees, not that everything went dark).
85
+ */
86
+ fun <T> withWifiDisabled(block: () -> T): T =
87
+ withTransportDisabled("wifi", "wifi_on", NetworkCapabilities.TRANSPORT_WIFI, block)
88
+
89
+ /** Run [block] with mobile data off (`svc data disable`), restoring the snapshotted state. */
90
+ fun <T> withMobileDataDisabled(block: () -> T): T =
91
+ withTransportDisabled("data", "mobile_data", NetworkCapabilities.TRANSPORT_CELLULAR, block)
92
+
93
+ private fun <T> withTransportDisabled(
94
+ svcName: String,
95
+ settingKey: String,
96
+ transport: Int,
97
+ block: () -> T,
98
+ ): T {
99
+ assumeEmulator("Disabling $svcName")
100
+ val prior = Shell.readSetting("global", settingKey) // "1" | "0" | null
101
+ Shell.exec("svc $svcName disable")
102
+ try {
103
+ awaitState("no active network on the '$svcName' transport") {
104
+ !activeNetworkHas(transport)
105
+ }
106
+ return block()
107
+ } finally {
108
+ if (prior != "0") {
109
+ Shell.exec("svc $svcName enable")
110
+ awaitState("'$svcName' transport restored") { activeNetworkHas(transport) }
111
+ }
112
+ }
113
+ }
114
+
115
+ private fun activeNetworkHas(transport: Int): Boolean {
116
+ val network = connectivity.activeNetwork ?: return false
117
+ return connectivity.getNetworkCapabilities(network)?.hasTransport(transport) == true
118
+ }
119
+
120
+ /** Bounded poll; fails naming [what] so a timeout diagnoses itself. */
121
+ private fun awaitState(what: String, timeoutMs: Long = SETTLE_TIMEOUT_MS, state: () -> Boolean) {
122
+ val deadline = SystemClock.elapsedRealtime() + timeoutMs
123
+ while (SystemClock.elapsedRealtime() < deadline) {
124
+ if (state()) return
125
+ Thread.sleep(200)
126
+ }
127
+ fail("network state never settled: waited ${timeoutMs}ms for $what")
128
+ }
129
+
130
+ private fun assumeEmulator(what: String) {
131
+ Shell.assumeOnEmulator(
132
+ "NetworkControl runs only on emulators (ro.kernel.qemu != 1 here). $what " +
133
+ "on a real phone cuts its owner's calls and messages — and over wifi-adb " +
134
+ "it severs the test harness itself. Run this suite on a stock QA AVD.",
135
+ )
136
+ }
137
+ }