create-cmp-cli 0.8.0 → 0.10.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 (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -0,0 +1,99 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.io.File
6
+ import java.text.SimpleDateFormat
7
+ import java.util.Date
8
+ import java.util.Locale
9
+ import java.util.TimeZone
10
+ import kotlinx.serialization.json.Json
11
+ import kotlinx.serialization.json.JsonElement
12
+ import kotlinx.serialization.json.JsonNull
13
+ import kotlinx.serialization.json.JsonPrimitive
14
+ import kotlinx.serialization.json.buildJsonArray
15
+ import kotlinx.serialization.json.buildJsonObject
16
+
17
+ /**
18
+ * Debug-only crash capture: [install] sets a process-wide
19
+ * [Thread.UncaughtExceptionHandler] that persists crash JSON to
20
+ * `filesDir/inspector/crashes/` (bounded to the last [MAX_CRASHES]) so a crash survives the
21
+ * process death that follows it — an in-memory ring buffer would not.
22
+ *
23
+ * MUST NEVER SWALLOW THE CRASH: after persisting, it always hands off to whatever handler was
24
+ * installed before it (chained, not replaced) so system crash dialogs, `System.exit`, and any
25
+ * other crash-reporting tool still behave exactly as if this class did not exist.
26
+ */
27
+ object CrashRecorder {
28
+
29
+ private const val TAG = "CmpInspector"
30
+ private const val MAX_CRASHES = 20
31
+
32
+ fun install(context: Context) {
33
+ val crashDir = File(context.filesDir, "inspector/crashes").apply { mkdirs() }
34
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
35
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
36
+ try {
37
+ persist(crashDir, throwable)
38
+ } catch (t: Throwable) {
39
+ // Persisting the crash record must never itself crash the crash handler.
40
+ Log.w(TAG, "failed to persist crash record", t)
41
+ } finally {
42
+ if (previous != null) {
43
+ previous.uncaughtException(thread, throwable)
44
+ } else {
45
+ // No previous handler installed: fall back to the JVM's own default so the
46
+ // process still dies the normal way instead of hanging.
47
+ Runtime.getRuntime().exit(10)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ private fun persist(crashDir: File, throwable: Throwable) {
54
+ val doc = buildJsonObject {
55
+ put("timestamp", JsonPrimitive(isoNow()))
56
+ put("exception", JsonPrimitive(throwable::class.qualifiedName ?: throwable.javaClass.name))
57
+ put("message", throwable.message?.let { JsonPrimitive(it) } ?: JsonNull)
58
+ put("frames", buildJsonArray {
59
+ throwable.stackTrace.forEach { el ->
60
+ add(buildJsonObject {
61
+ put("className", JsonPrimitive(el.className))
62
+ put("methodName", JsonPrimitive(el.methodName))
63
+ put("fileName", el.fileName?.let { JsonPrimitive(it) } ?: JsonNull)
64
+ put("lineNumber", JsonPrimitive(el.lineNumber))
65
+ })
66
+ }
67
+ })
68
+ }
69
+ File(crashDir, "crash-${System.currentTimeMillis()}.json")
70
+ .writeText(Json.encodeToString(JsonElement.serializer(), doc))
71
+ prune(crashDir)
72
+ }
73
+
74
+ /** Keep only the most recent [MAX_CRASHES] crash files (current boot + previous ones). */
75
+ private fun prune(crashDir: File) {
76
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
77
+ if (files.size <= MAX_CRASHES) return
78
+ files.sortedBy { it.lastModified() }
79
+ .take(files.size - MAX_CRASHES)
80
+ .forEach { it.delete() }
81
+ }
82
+
83
+ /**
84
+ * Every persisted crash's raw JSON text, newest first — spans the current boot AND any
85
+ * previous ones ([install] never clears the directory, only [prune] bounds it).
86
+ */
87
+ fun readAll(context: Context): List<String> {
88
+ val crashDir = File(context.filesDir, "inspector/crashes")
89
+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return emptyList()
90
+ return files.sortedByDescending { it.lastModified() }
91
+ .mapNotNull { runCatching { it.readText() }.getOrNull() }
92
+ }
93
+
94
+ private fun isoNow(): String {
95
+ val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
96
+ fmt.timeZone = TimeZone.getTimeZone("UTC")
97
+ return fmt.format(Date())
98
+ }
99
+ }
@@ -0,0 +1,144 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ // >>> cmp:feature room
4
+ import __PACKAGE__.data.local.AppDatabase
5
+ import androidx.room.useReaderConnection
6
+ import kotlinx.coroutines.runBlocking
7
+ import kotlinx.serialization.json.Json
8
+ import kotlinx.serialization.json.JsonElement
9
+ import kotlinx.serialization.json.JsonNull
10
+ import kotlinx.serialization.json.JsonPrimitive
11
+ import kotlinx.serialization.json.buildJsonArray
12
+ import kotlinx.serialization.json.buildJsonObject
13
+ import org.koin.core.context.GlobalContext
14
+ // <<< cmp:feature room
15
+
16
+ /**
17
+ * `GET /inspect/db` (schema: tables via `sqlite_master`) and `GET /inspect/db?table=<name>&limit=<n>`
18
+ * (rows). Read-only, off the main thread. 404 when this project's local-database feature is off
19
+ * (there is nothing to query) — see the `room`-gated implementation below for the real path.
20
+ */
21
+ object DbInspector {
22
+
23
+ // >>> cmp:feature room
24
+ // Reads go through the project's Room database (a Koin single). Injection-safe by
25
+ // construction: a requested `table` is only ever used in a query after it is proven to be
26
+ // a real name returned by `sqlite_master` in THIS call — the raw wire value never reaches
27
+ // SQL beyond that validated identifier.
28
+ //
29
+ // This project's Room config uses the KMP driver architecture (BundledSQLiteDriver, see
30
+ // data/local/DatabaseBuilder.kt), not the legacy Android-only SupportSQLiteDatabase — so
31
+ // reads go through Room 2.8's public `useReaderConnection { transactor -> ... }` whose
32
+ // receiver is a pooled connection exposing `usePrepared(sql) { stmt -> ... }` (the
33
+ // statement is created and closed by Room; binds are 1-based, column reads 0-based).
34
+ private const val DEFAULT_ROW_LIMIT = 50
35
+ private const val MAX_ROW_LIMIT = 500
36
+ private val VALID_IDENTIFIER = Regex("^[A-Za-z_][A-Za-z0-9_]*$")
37
+ private val prettyJson = Json { prettyPrint = true }
38
+
39
+ private fun appDatabaseOrNull(): AppDatabase? =
40
+ try {
41
+ // GlobalContext.getOrNull() IS the Koin instance (or null before startKoin).
42
+ GlobalContext.getOrNull()?.getOrNull<AppDatabase>()
43
+ } catch (t: Throwable) {
44
+ null
45
+ }
46
+
47
+ fun schema(): Pair<Int, String> {
48
+ val db = appDatabaseOrNull()
49
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
50
+ return try {
51
+ val tables = runBlocking {
52
+ db.useReaderConnection { connection ->
53
+ connection.usePrepared(
54
+ "SELECT name, sql FROM sqlite_master WHERE type = 'table' " +
55
+ "AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%' ORDER BY name"
56
+ ) { stmt ->
57
+ val out = mutableListOf<Pair<String, String?>>()
58
+ while (stmt.step()) {
59
+ out += stmt.getText(0) to (if (stmt.isNull(1)) null else stmt.getText(1))
60
+ }
61
+ out
62
+ }
63
+ }
64
+ }
65
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
66
+ put("tables", buildJsonArray {
67
+ tables.forEach { (name, sql) ->
68
+ add(buildJsonObject {
69
+ put("name", JsonPrimitive(name))
70
+ put("sql", sql?.let { JsonPrimitive(it) } ?: JsonNull)
71
+ })
72
+ }
73
+ })
74
+ })
75
+ } catch (t: Throwable) {
76
+ 500 to errorJson("failed to read schema: ${t.message}")
77
+ }
78
+ }
79
+
80
+ /** Row page for one table: columns + stringified values, capped. */
81
+ private class TableRows(val columns: List<String>, val rows: List<JsonElement>)
82
+
83
+ fun rows(table: String, limitParam: String?): Pair<Int, String> {
84
+ if (!VALID_IDENTIFIER.matches(table)) {
85
+ return 400 to errorJson("invalid table name '$table' — expected a plain SQL identifier.")
86
+ }
87
+ val db = appDatabaseOrNull()
88
+ ?: return 503 to errorJson("database not available yet (Room not initialised — is Koin started?).")
89
+ val limit = (limitParam?.toIntOrNull() ?: DEFAULT_ROW_LIMIT).coerceIn(1, MAX_ROW_LIMIT)
90
+ return try {
91
+ val result: TableRows? = runBlocking {
92
+ db.useReaderConnection { connection ->
93
+ // STRICT validation: `table` is only used in the row query below once THIS
94
+ // check proves it is a real sqlite_master identifier — never the raw wire value.
95
+ val exists = connection.usePrepared(
96
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
97
+ ) { stmt ->
98
+ stmt.bindText(1, table)
99
+ stmt.step()
100
+ }
101
+ if (!exists) return@useReaderConnection null
102
+
103
+ connection.usePrepared("SELECT * FROM \"$table\" LIMIT ?") { stmt ->
104
+ stmt.bindLong(1, limit.toLong())
105
+ val colCount = stmt.getColumnCount()
106
+ val columns = (0 until colCount).map { stmt.getColumnName(it) }
107
+ val rows = mutableListOf<JsonElement>()
108
+ while (stmt.step()) {
109
+ rows += buildJsonObject {
110
+ for (i in 0 until colCount) {
111
+ put(columns[i], if (stmt.isNull(i)) JsonNull else JsonPrimitive(stmt.getText(i)))
112
+ }
113
+ }
114
+ }
115
+ TableRows(columns, rows)
116
+ }
117
+ }
118
+ }
119
+ if (result == null) {
120
+ return 404 to errorJson("unknown table '$table' — not present in sqlite_master.")
121
+ }
122
+ 200 to prettyJson.encodeToString(JsonElement.serializer(), buildJsonObject {
123
+ put("table", JsonPrimitive(table))
124
+ put("columns", buildJsonArray { result.columns.forEach { add(JsonPrimitive(it)) } })
125
+ put("rows", buildJsonArray { result.rows.forEach { add(it) } })
126
+ put("rowCount", JsonPrimitive(result.rows.size))
127
+ })
128
+ } catch (t: Throwable) {
129
+ 500 to errorJson("failed to read table '$table': ${t.message}")
130
+ }
131
+ }
132
+
133
+ private fun errorJson(message: String): String =
134
+ """{"error":${JsonPrimitive(message)}}"""
135
+ // <<< cmp:feature room
136
+ // >>> cmp:feature !room
137
+ private const val DISABLED_MESSAGE =
138
+ "the 'room' feature is disabled in this project — /inspect/db is unavailable."
139
+
140
+ fun schema(): Pair<Int, String> = 404 to """{"error":"$DISABLED_MESSAGE"}"""
141
+
142
+ fun rows(table: String, limitParam: String?): Pair<Int, String> = schema()
143
+ // <<< cmp:feature !room
144
+ }
@@ -5,9 +5,12 @@ import androidx.compose.ui.graphics.toArgb
5
5
  import androidx.compose.ui.unit.Dp
6
6
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
7
7
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
8
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__TypeRamp
8
9
  import kotlinx.serialization.json.Json
9
10
  import kotlinx.serialization.json.JsonElement
11
+ import kotlinx.serialization.json.JsonNull
10
12
  import kotlinx.serialization.json.JsonPrimitive
13
+ import kotlinx.serialization.json.buildJsonArray
11
14
  import kotlinx.serialization.json.buildJsonObject
12
15
  import kotlinx.serialization.json.put
13
16
 
@@ -57,6 +60,22 @@ object InspectorCatalog {
57
60
  put("RadiusModal", __THEME_PREFIX__Tokens.RadiusModal.token())
58
61
  put("RadiusInput", __THEME_PREFIX__Tokens.RadiusInput.token())
59
62
  })
63
+ // The type ramp, from the same ramp data the Typography factory builds
64
+ // its styles from — so the LIVE tier answers with the same block the
65
+ // headless preview catalog writes, and the console renders one ramp
66
+ // whichever tier it read.
67
+ put("typography", buildJsonArray {
68
+ __THEME_PREFIX__TypeRamp.forEach { spec ->
69
+ add(buildJsonObject {
70
+ put("name", spec.name)
71
+ put("weight", spec.weight)
72
+ put("size", "${spec.sizeSp}sp")
73
+ put("lineHeight", "${spec.lineHeightSp}sp")
74
+ val tracking = spec.tracking
75
+ if (tracking == null) put("tracking", JsonNull) else put("tracking", "${tracking}sp")
76
+ })
77
+ }
78
+ })
60
79
  }
61
80
  return prettyJson.encodeToString(JsonElement.serializer(), doc)
62
81
  }
@@ -1,12 +1,20 @@
1
1
  package __PACKAGE__.inspector
2
2
 
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.ContextWrapper
3
6
  import android.graphics.Bitmap
4
7
  import android.graphics.Canvas
8
+ import android.graphics.Rect
9
+ import android.os.Build
5
10
  import android.os.Handler
6
11
  import android.os.Looper
7
12
  import android.os.SystemClock
8
13
  import android.util.Log
9
14
  import android.view.MotionEvent
15
+ import android.view.PixelCopy
16
+ import android.view.View
17
+ import android.view.Window
10
18
  import androidx.core.view.drawToBitmap
11
19
  import java.io.BufferedReader
12
20
  import java.io.ByteArrayOutputStream
@@ -14,6 +22,7 @@ import java.io.InputStreamReader
14
22
  import java.net.InetAddress
15
23
  import java.net.ServerSocket
16
24
  import java.net.Socket
25
+ import java.net.URLDecoder
17
26
  import java.nio.charset.StandardCharsets
18
27
  import java.util.concurrent.CountDownLatch
19
28
  import java.util.concurrent.TimeUnit
@@ -43,6 +52,15 @@ import kotlin.math.roundToInt
43
52
  * pair to the root view → {"tapped":true,"x":…,"y":…}.
44
53
  * GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
45
54
  * zero CORS): live screenshot + click-to-tap in a browser.
55
+ * GET /inspect/nav → { currentRoute, backStack } — best-effort, reported by the
56
+ * common `NavInspectionHook` seam; empty snapshot before the
57
+ * first navigation event (see [NavInspector]).
58
+ * GET /inspect/crashes → { crashes: [...] } — persisted crash JSON (current boot +
59
+ * previous ones), newest first (see [CrashRecorder]).
60
+ * GET /inspect/db → schema: { tables:[{name,sql}] } via `sqlite_master`.
61
+ * GET /inspect/db?table=<n>&limit=<n> → rows for one table (read-only, bounded; see
62
+ * [DbInspector]). 404 (or empty schema) when the project's
63
+ * `room` feature is off.
46
64
  *
47
65
  * Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
48
66
  * Failure to bind logs a warning and gives up — the inspector must never crash or block
@@ -67,9 +85,14 @@ object InspectorHttpServer {
67
85
 
68
86
  @Volatile private var started = false
69
87
 
70
- fun start(appId: String) {
88
+ // Set once in [start]; read from the HTTP thread only (crashes/db routes). applicationContext
89
+ // is safe to hold — it never leaks an Activity.
90
+ @Volatile private var appContext: Context? = null
91
+
92
+ fun start(appId: String, context: Context) {
71
93
  if (started) return
72
94
  started = true
95
+ appContext = context.applicationContext
73
96
  val thread = Thread({ serve(appId) }, "cmp-inspector-http")
74
97
  thread.isDaemon = true
75
98
  thread.start()
@@ -114,7 +137,9 @@ object InspectorHttpServer {
114
137
  }
115
138
  val parts = requestLine.split(" ")
116
139
  val method = parts.getOrNull(0) ?: ""
117
- val path = (parts.getOrNull(1) ?: "").substringBefore('?')
140
+ val rawTarget = parts.getOrNull(1) ?: ""
141
+ val path = rawTarget.substringBefore('?')
142
+ val query = rawTarget.substringAfter('?', "")
118
143
 
119
144
  when {
120
145
  method == "GET" && path == "/inspect/health" ->
@@ -129,6 +154,17 @@ object InspectorHttpServer {
129
154
  writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
130
155
  method == "POST" && path == "/inspect/tap" ->
131
156
  tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
157
+ method == "GET" && path == "/inspect/nav" ->
158
+ writeJson(client, 200, navJson())
159
+ // The JUMP half of the nav seam (the read half is /inspect/nav above): coverage
160
+ // by route, not by synthesized taps. GET (not POST) deliberately — it is
161
+ // idempotent-ish debug tooling meant to be curl-able, like everything else here.
162
+ method == "GET" && path == "/inspect/navigate" ->
163
+ navigateResponse(query).let { (s, b) -> writeJson(client, s, b) }
164
+ method == "GET" && path == "/inspect/crashes" ->
165
+ writeJson(client, 200, crashesJson())
166
+ method == "GET" && path == "/inspect/db" ->
167
+ dbResponse(query).let { (s, b) -> writeJson(client, s, b) }
132
168
  method != "GET" && method != "POST" ->
133
169
  writeJson(client, 405, errorJson("method not allowed"))
134
170
  else ->
@@ -153,8 +189,35 @@ object InspectorHttpServer {
153
189
  return String(buf, 0, read)
154
190
  }
155
191
 
156
- private fun healthJson(appId: String): String =
157
- """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug"}"""
192
+ private fun healthJson(appId: String): String {
193
+ // Process start as epoch millis: elapsedRealtime anchors the boot clock to the wall
194
+ // clock. This is the DETERMINISM primitive — an external relaunch (force-stop +
195
+ // launch) is VERIFIED by this value moving forward, so a walk can prove it started
196
+ // from a fresh process instead of trusting that a retained ViewModel isn't lurking.
197
+ val startedAtMs = System.currentTimeMillis() - android.os.SystemClock.elapsedRealtime() +
198
+ android.os.Process.getStartElapsedRealtime()
199
+ return """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug",""" +
200
+ """"processStartedAtMs":$startedAtMs,"processUptimeMs":${android.os.SystemClock.elapsedRealtime() - android.os.Process.getStartElapsedRealtime()}}"""
201
+ }
202
+
203
+ private fun navigateResponse(query: String): Pair<Int, String> {
204
+ val route = query.split('&')
205
+ .firstOrNull { it.startsWith("route=") }
206
+ ?.substringAfter('=')
207
+ ?.let { java.net.URLDecoder.decode(it, "UTF-8") }
208
+ ?.takeIf { it.isNotBlank() }
209
+ ?: return 400 to errorJson("missing route parameter — /inspect/navigate?route=<route>")
210
+ val error = NavInspector.navigate(route)
211
+ return if (error == null) {
212
+ 200 to """{"ok":true,"route":${JsonPrimitive(route)}}"""
213
+ } else if (error.startsWith("nav host not composed")) {
214
+ 503 to errorJson(error)
215
+ } else if (error.startsWith("unknown route")) {
216
+ 404 to errorJson(error)
217
+ } else {
218
+ 500 to errorJson(error)
219
+ }
220
+ }
158
221
 
159
222
  private fun treeResponse(): Pair<Int, String> {
160
223
  val root = ComposeRootRegistry.current()
@@ -183,10 +246,62 @@ object InspectorHttpServer {
183
246
  }
184
247
  }
185
248
 
249
+ /** { currentRoute, backStack } from [NavInspector] — best-effort, never blocks. */
250
+ private fun navJson(): String {
251
+ val snapshot = NavInspector.current()
252
+ val currentRouteJson = snapshot.currentRoute?.let { JsonPrimitive(it).toString() } ?: "null"
253
+ val backStackJson = snapshot.backStack.joinToString(",") { JsonPrimitive(it).toString() }
254
+ return """{"currentRoute":$currentRouteJson,"backStack":[$backStackJson]}"""
255
+ }
256
+
257
+ /** { crashes:[...] } — each element is a persisted crash JSON document, verbatim. */
258
+ private fun crashesJson(): String {
259
+ val ctx = appContext ?: return """{"crashes":[]}"""
260
+ val crashes = CrashRecorder.readAll(ctx)
261
+ return """{"crashes":[${crashes.joinToString(",")}]}"""
262
+ }
263
+
264
+ /** GET /inspect/db dispatch: no `table` → schema, else → rows for that table. */
265
+ private fun dbResponse(query: String): Pair<Int, String> {
266
+ val params = parseQuery(query)
267
+ val table = params["table"]
268
+ return if (table == null) DbInspector.schema() else DbInspector.rows(table, params["limit"])
269
+ }
270
+
271
+ /** Minimal `a=b&c=d` query-string parser (URL-decoded values). Last value wins on repeats. */
272
+ private fun parseQuery(query: String): Map<String, String> {
273
+ if (query.isEmpty()) return emptyMap()
274
+ val out = mutableMapOf<String, String>()
275
+ for (pair in query.split("&")) {
276
+ if (pair.isEmpty()) continue
277
+ val eq = pair.indexOf('=')
278
+ val key = if (eq >= 0) pair.substring(0, eq) else pair
279
+ val value = if (eq >= 0) pair.substring(eq + 1) else ""
280
+ out[urlDecode(key)] = urlDecode(value)
281
+ }
282
+ return out
283
+ }
284
+
285
+ private fun urlDecode(s: String): String =
286
+ try {
287
+ URLDecoder.decode(s, "UTF-8")
288
+ } catch (t: Throwable) {
289
+ s
290
+ }
291
+
186
292
  /**
187
- * PNG of the current Compose root. The Bitmap is rendered on the MAIN thread (views are
188
- * not thread-safe); PNG compression tens of ms for a full screen happens back on the
189
- * server thread so the UI never pays for it.
293
+ * PNG of the current Compose root. Pixels are read with [PixelCopy] (API 26+) from the
294
+ * COMPOSITED window surfacethe same source `adb screencap` readsso a capture can
295
+ * never replay a stale Compose layer recording. The software fallback (`View.draw` into a
296
+ * bitmap canvas) replays recorded display lists, and a nav-transition `graphicsLayer`
297
+ * whose recording predates the current frame replays the PREVIOUS screen: byte-identical
298
+ * "screenshots" of two different screens, detectable only by hash-compare. PixelCopy is
299
+ * therefore the primary path; the draw fallback remains for pre-26, windowless roots, and
300
+ * dialog/popup roots (their content lives in a different window than the Activity's).
301
+ *
302
+ * The capture runs on the MAIN thread (views are not thread-safe; PixelCopy's listener is
303
+ * delivered there too); PNG compression — tens of ms for a full screen — happens back on
304
+ * the server thread so the UI never pays for it.
190
305
  */
191
306
  private fun screenshotResponse(client: Socket) {
192
307
  val root = ComposeRootRegistry.current()
@@ -200,22 +315,35 @@ object InspectorHttpServer {
200
315
  val bitmapRef = AtomicReference<Bitmap?>()
201
316
  val errorRef = AtomicReference<String?>()
202
317
  val latch = CountDownLatch(1)
203
- Handler(Looper.getMainLooper()).post {
318
+ val main = Handler(Looper.getMainLooper())
319
+ main.post {
204
320
  try {
205
321
  val view = root.view
206
- bitmapRef.set(
207
- try {
208
- // androidx.core.view.drawToBitmap (core-ktx already an androidMain dep).
209
- view.drawToBitmap()
210
- } catch (t: Throwable) {
211
- // Not laid out yet / hardware path refused — plain Canvas draw fallback.
212
- Bitmap.createBitmap(
213
- view.width.coerceAtLeast(1),
214
- view.height.coerceAtLeast(1),
215
- Bitmap.Config.ARGB_8888,
216
- ).also { view.draw(Canvas(it)) }
217
- }
218
- )
322
+ // PixelCopy only when this root actually lives in the Activity's window —
323
+ // a dialog/popup root has its own window, and copying the Activity's would
324
+ // capture the screen BENEATH it. Those fall back to the software draw.
325
+ val window = windowOf(view)?.takeIf { it.decorView === view.rootView }
326
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && window != null) {
327
+ val loc = IntArray(2).also(view::getLocationInWindow)
328
+ val bmp = Bitmap.createBitmap(
329
+ view.width.coerceAtLeast(1),
330
+ view.height.coerceAtLeast(1),
331
+ Bitmap.Config.ARGB_8888,
332
+ )
333
+ val src = Rect(loc[0], loc[1], loc[0] + bmp.width, loc[1] + bmp.height)
334
+ PixelCopy.request(window, src, bmp, { result ->
335
+ try {
336
+ // No composited frame to copy (mid-transition, surface torn down):
337
+ // fall back rather than fail — a fallback frame beats no frame.
338
+ bitmapRef.set(if (result == PixelCopy.SUCCESS) bmp else softwareDraw(view))
339
+ } catch (t: Throwable) {
340
+ errorRef.set("failed to render screenshot: ${t.message}")
341
+ }
342
+ latch.countDown()
343
+ }, main)
344
+ return@post // countDown happens in the PixelCopy listener above
345
+ }
346
+ bitmapRef.set(softwareDraw(view))
219
347
  } catch (t: Throwable) {
220
348
  errorRef.set("failed to render screenshot: ${t.message}")
221
349
  }
@@ -242,6 +370,34 @@ object InspectorHttpServer {
242
370
  writeResponse(client, 200, bytes, PNG_TYPE)
243
371
  }
244
372
 
373
+ /** The view's host [Window], unwrapped through the ContextWrapper chain. MAIN thread. */
374
+ private fun windowOf(view: View): Window? {
375
+ var ctx = view.context
376
+ while (ctx is ContextWrapper) {
377
+ if (ctx is Activity) return ctx.window
378
+ ctx = ctx.baseContext
379
+ }
380
+ return null
381
+ }
382
+
383
+ /**
384
+ * Software-canvas capture — re-issues the view's draw. Correct for laid-out static content,
385
+ * but can replay a stale `graphicsLayer` recording mid-transition (see [screenshotResponse]);
386
+ * used only where PixelCopy can't be (pre-API-26, windowless, or dialog roots). MAIN thread.
387
+ */
388
+ private fun softwareDraw(view: View): Bitmap =
389
+ try {
390
+ // androidx.core.view.drawToBitmap (core-ktx — already an androidMain dep).
391
+ view.drawToBitmap()
392
+ } catch (t: Throwable) {
393
+ // Not laid out yet / hardware path refused — plain Canvas draw fallback.
394
+ Bitmap.createBitmap(
395
+ view.width.coerceAtLeast(1),
396
+ view.height.coerceAtLeast(1),
397
+ Bitmap.Config.ARGB_8888,
398
+ ).also { view.draw(Canvas(it)) }
399
+ }
400
+
245
401
  /**
246
402
  * Dispatch a synthetic tap (ACTION_DOWN, then ACTION_UP ~50ms later) to the topmost
247
403
  * Compose root, on the MAIN thread. Coordinates are root-relative px — exactly the
@@ -3,14 +3,18 @@ package __PACKAGE__.inspector
3
3
  import android.app.Application
4
4
 
5
5
  /**
6
- * DEBUG variant: install the Compose root registry (must happen BEFORE any Activity so the
7
- * `onViewCreatedCallback` hook catches every root), then start the loopback-only inspection
8
- * server on 127.0.0.1:9500. Reach it from the host via `adb forward tcp:9500 tcp:9500`.
6
+ * DEBUG variant: install the Compose root registry and the nav-inspection listener (both must
7
+ * happen BEFORE any Activity — the registry so `onViewCreatedCallback` catches every root, the
8
+ * nav listener so the first `AppNavHost` composition is observed), chain in the crash recorder,
9
+ * then start the loopback-only inspection server on 127.0.0.1:9500. Reach it from the host via
10
+ * `adb forward tcp:9500 tcp:9500`.
9
11
  *
10
12
  * The release source set carries a same-signature no-op twin — the compiler picks the variant
11
13
  * body, so release builds contain no inspector code at all (structural absence, not a flag).
12
14
  */
13
15
  fun Application.startInspector() {
14
16
  ComposeRootRegistry.install()
15
- InspectorHttpServer.start(appId = packageName)
17
+ NavInspector.install()
18
+ CrashRecorder.install(this)
19
+ InspectorHttpServer.start(appId = packageName, context = this)
16
20
  }
@@ -26,6 +26,9 @@ import kotlin.math.roundToInt
26
26
  * - `role` string|null — [SemanticsProperties.Role] (e.g. "Button").
27
27
  * - `clickable` boolean — presence of [SemanticsActions.OnClick].
28
28
  * - `disabled` boolean — presence of [SemanticsProperties.Disabled].
29
+ * - `size` {width,height} — the FULL composed (unclipped) size; `bounds` is the visible
30
+ * slice after ancestor clipping (a scroll fold truncates it). The a11y audit
31
+ * judges touch targets on `size` so fold-clipped rows never false-flag.
29
32
  */
30
33
  object LiveSemanticsJson {
31
34
 
@@ -49,6 +52,7 @@ object LiveSemanticsJson {
49
52
  put("clickable", JsonPrimitive(node.isClickable()))
50
53
  put("disabled", JsonPrimitive(node.isDisabled()))
51
54
  put("bounds", node.boundsJson())
55
+ put("size", node.sizeJson())
52
56
  put("designToken", node.designTokenJson())
53
57
  put("children", buildJsonArray {
54
58
  node.children.forEach { add(nodeToJson(it)) }
@@ -87,6 +91,12 @@ object LiveSemanticsJson {
87
91
  }
88
92
  }
89
93
 
94
+ /** Full composed (unclipped) size — see the class doc's `size` contract note. */
95
+ private fun SemanticsNode.sizeJson(): JsonObject = buildJsonObject {
96
+ put("width", JsonPrimitive(size.width))
97
+ put("height", JsonPrimitive(size.height))
98
+ }
99
+
90
100
  private fun SemanticsNode.designTokenJson(): JsonElement {
91
101
  val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
92
102
  return buildJsonObject {