create-cmp-cli 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/package.json +1 -1
- 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 +48 -6
- 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/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- 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/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +637 -56
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
|
@@ -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
|
+
}
|