From 0815f6a4f985f6f3e630d386b389dfc1c82da519 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 10:21:38 -0700 Subject: [PATCH] feat(android): add Wear proxy protocol (#108835) * feat(android): add Wear proxy protocol Introduce a bounded, versioned phone/watch RPC contract and wire its test, lint, and build gates before either runtime endpoint lands. Co-authored-by: Sebastian Schubotz * test(android): lock Wear wire names Cover every supported RPC method and event discriminator so phone and watch cannot silently drift. Co-authored-by: Sebastian Schubotz * fix(android): bound Wear JSON depth Reject excessively nested Data Layer envelopes before kotlinx.serialization can recurse through them. * fix(android): enforce Wear depth on encode Keep outbound messages inside the same JSON nesting contract enforced by the decoder. * fix(android): preflight Wear payload depth Traverse arbitrary JSON payloads iteratively before serialization so deeply nested local trees fail safely. --------- Co-authored-by: Sebastian Schubotz Co-authored-by: Sebastian Schubotz --- .github/workflows/ci.yml | 13 +- apps/android/build.gradle.kts | 1 + apps/android/gradle/libs.versions.toml | 1 + apps/android/settings.gradle.kts | 1 + apps/android/wear-shared/build.gradle.kts | 44 +++ .../wear-shared/src/main/AndroidManifest.xml | 1 + .../ai/openclaw/wear/shared/WearProtocol.kt | 258 ++++++++++++++++++ .../openclaw/wear/shared/WearProtocolTest.kt | 190 +++++++++++++ package.json | 10 +- 9 files changed, 511 insertions(+), 8 deletions(-) create mode 100644 apps/android/wear-shared/build.gradle.kts create mode 100644 apps/android/wear-shared/src/main/AndroidManifest.xml create mode 100644 apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt create mode 100644 apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd6c478cda9..4c8d267d6b95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2897,7 +2897,9 @@ jobs: set -euo pipefail case "$TASK" in test-play) - ./gradlew --no-daemon --build-cache :app:testPlayDebugUnitTest + ./gradlew --no-daemon --build-cache \ + :app:testPlayDebugUnitTest \ + :wear-shared:testDebugUnitTest ;; test-third-party) ./gradlew --no-daemon --build-cache :app:testThirdPartyDebugUnitTest @@ -2908,7 +2910,9 @@ jobs: :app:assembleThirdPartyDebug \ :app:lintPlayDebug \ :app:lintThirdPartyDebug \ - :benchmark:assembleDebug + :benchmark:assembleDebug \ + :wear-shared:assembleDebug \ + :wear-shared:lintDebug ;; build-play-compat) # Frozen targets keep their target-owned Android build contract. New lint rules @@ -2917,7 +2921,10 @@ jobs: ;; ktlint) # Mirrors `pnpm android:lint`; keeps formatting drift out of main (see PR #100304 sweep). - ./gradlew --no-daemon --build-cache :app:ktlintCheck :benchmark:ktlintCheck + ./gradlew --no-daemon --build-cache \ + :app:ktlintCheck \ + :benchmark:ktlintCheck \ + :wear-shared:ktlintCheck ;; *) echo "Unsupported Android task: $TASK" >&2 diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 07feb467e64f..4753ca107f51 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.ktlint) apply false alias(libs.plugins.kotlin.compose) apply false diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index ed81c9be2523..125350e1b852 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -80,6 +80,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts index 25e5d09bbe1d..b7d2c3de27c7 100644 --- a/apps/android/settings.gradle.kts +++ b/apps/android/settings.gradle.kts @@ -17,3 +17,4 @@ dependencyResolutionManagement { rootProject.name = "OpenClawNodeAndroid" include(":app") include(":benchmark") +include(":wear-shared") diff --git a/apps/android/wear-shared/build.gradle.kts b/apps/android/wear-shared/build.gradle.kts new file mode 100644 index 000000000000..435caedb27d2 --- /dev/null +++ b/apps/android/wear-shared/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "ai.openclaw.wear.shared" + compileSdk = 37 + + defaultConfig { + minSdk = 31 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint { + warningsAsErrors = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + allWarningsAsErrors.set(true) + } +} + +ktlint { + android.set(true) + ignoreFailures.set(false) + filter { + exclude("**/build/**") + } +} + +dependencies { + api(libs.kotlinx.serialization.json) + + testImplementation(libs.junit) +} diff --git a/apps/android/wear-shared/src/main/AndroidManifest.xml b/apps/android/wear-shared/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..cc947c567995 --- /dev/null +++ b/apps/android/wear-shared/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt new file mode 100644 index 000000000000..53d5e1bcb70b --- /dev/null +++ b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -0,0 +1,258 @@ +package ai.openclaw.wear.shared + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import java.nio.charset.CharacterCodingException + +object WearProtocol { + const val VERSION = 1 + const val REQUEST_PATH = "/openclaw/wear/v1/request" + const val RESPONSE_PATH = "/openclaw/wear/v1/response" + const val EVENT_PATH = "/openclaw/wear/v1/event" + + // MessageClient has a 100 KiB ceiling. Keep headroom for transport metadata and + // force transcript pagination instead of depending on an edge-sized message. + const val MAX_MESSAGE_BYTES = 64 * 1024 + + // Bound recursive JSON parsing at the untrusted Data Layer boundary. + const val MAX_JSON_DEPTH = 32 +} + +@Serializable +enum class WearRpcMethod { + @SerialName("proxy.status") + ProxyStatus, + + @SerialName("sessions.list") + SessionsList, + + @SerialName("chat.history") + ChatHistory, + + @SerialName("chat.send") + ChatSend, + + @SerialName("chat.abort") + ChatAbort, +} + +@Serializable +enum class WearEventType { + @SerialName("chat") + Chat, + + @SerialName("connection") + Connection, +} + +@Serializable +sealed interface WearMessage { + val version: Int + + @Serializable + @SerialName("request") + data class Request( + override val version: Int = WearProtocol.VERSION, + val requestId: String, + val method: WearRpcMethod, + val params: JsonObject = buildJsonObject {}, + ) : WearMessage + + @Serializable + @SerialName("response") + data class Response( + override val version: Int = WearProtocol.VERSION, + val requestId: String, + val ok: Boolean, + val result: JsonElement? = null, + val error: WearRpcError? = null, + ) : WearMessage + + @Serializable + @SerialName("event") + data class Event( + override val version: Int = WearProtocol.VERSION, + val sequence: Long, + val event: WearEventType, + val payload: JsonElement? = null, + ) : WearMessage +} + +@Serializable +data class WearRpcError( + val code: String, + val message: String, +) + +enum class WearDecodeFailureReason { + Empty, + TooLarge, + TooDeep, + Malformed, + UnsupportedVersion, + InvalidEnvelope, +} + +sealed interface WearDecodeResult { + data class Success( + val message: WearMessage, + ) : WearDecodeResult + + data class Failure( + val reason: WearDecodeFailureReason, + ) : WearDecodeResult +} + +object WearProtocolCodec { + private val json = + Json { + classDiscriminator = "type" + encodeDefaults = true + explicitNulls = false + ignoreUnknownKeys = true + } + + fun encode(message: WearMessage): ByteArray { + requireValid(message) + require(hasValidPayloadDepth(message)) { + "Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}" + } + val encoded = json.encodeToString(WearMessage.serializer(), message) + require(!exceedsJsonDepth(encoded)) { + "Wear message exceeds JSON depth ${WearProtocol.MAX_JSON_DEPTH}" + } + val bytes = + encoded.encodeToByteArray(throwOnInvalidSequence = true) + require(bytes.size <= WearProtocol.MAX_MESSAGE_BYTES) { + "Wear message exceeds ${WearProtocol.MAX_MESSAGE_BYTES} bytes" + } + return bytes + } + + private fun hasValidPayloadDepth(message: WearMessage): Boolean { + val payloads = + when (message) { + is WearMessage.Request -> listOf(message.params) + is WearMessage.Response -> listOfNotNull(message.result) + is WearMessage.Event -> listOfNotNull(message.payload) + } + return payloads.all { element -> hasValidElementDepth(element, parentDepth = 1) } + } + + private fun hasValidElementDepth( + element: JsonElement, + parentDepth: Int, + ): Boolean { + val pending = ArrayDeque>() + pending.addLast(element to parentDepth) + while (pending.isNotEmpty()) { + val (current, parent) = pending.removeLast() + val children = + when (current) { + is JsonArray -> current + is JsonObject -> current.values + else -> continue + } + val depth = parent + 1 + if (depth > WearProtocol.MAX_JSON_DEPTH) return false + children.forEach { child -> pending.addLast(child to depth) } + } + return true + } + + fun decode(bytes: ByteArray): WearDecodeResult { + if (bytes.isEmpty()) return WearDecodeResult.Failure(WearDecodeFailureReason.Empty) + if (bytes.size > WearProtocol.MAX_MESSAGE_BYTES) { + return WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge) + } + + val text = + try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (_: CharacterCodingException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + if (exceedsJsonDepth(text)) { + return WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep) + } + val root = + try { + json.parseToJsonElement(text).jsonObject + } catch (_: SerializationException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } catch (_: IllegalArgumentException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + val version = + (root["version"] as? JsonPrimitive)?.intOrNull + ?: return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + if (version != WearProtocol.VERSION) { + return WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion) + } + val message = + try { + json.decodeFromJsonElement(WearMessage.serializer(), root) + } catch (_: SerializationException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } catch (_: IllegalArgumentException) { + return WearDecodeResult.Failure(WearDecodeFailureReason.Malformed) + } + if (!isValid(message)) { + return WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope) + } + return WearDecodeResult.Success(message) + } + + private fun exceedsJsonDepth(text: String): Boolean { + var depth = 0 + var inString = false + var escaped = false + for (character in text) { + if (inString) { + when { + escaped -> escaped = false + character == '\\' -> escaped = true + character == '"' -> inString = false + } + continue + } + + when (character) { + '"' -> inString = true + '{', '[' -> { + depth += 1 + if (depth > WearProtocol.MAX_JSON_DEPTH) return true + } + '}', ']' -> depth -= 1 + } + } + return false + } + + private fun requireValid(message: WearMessage) { + require(message.version == WearProtocol.VERSION) { "Unsupported Wear protocol version: ${message.version}" } + require(isValid(message)) { "Invalid Wear protocol envelope" } + } + + private fun isValid(message: WearMessage): Boolean = + when (message) { + is WearMessage.Request -> message.requestId.isNotBlank() + is WearMessage.Response -> + message.requestId.isNotBlank() && + if (message.ok) { + message.error == null + } else { + message.error != null && message.result == null && message.error.code.isNotBlank() + } + is WearMessage.Event -> message.sequence >= 0 + } +} diff --git a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt new file mode 100644 index 000000000000..8ddc91f7a865 --- /dev/null +++ b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -0,0 +1,190 @@ +package ai.openclaw.wear.shared + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class WearProtocolTest { + @Test + fun roundTripsEveryEnvelopeKind() { + val messages = + listOf( + WearMessage.Request( + requestId = "req-1", + method = WearRpcMethod.ChatHistory, + params = buildJsonObject { put("sessionKey", "main") }, + ), + WearMessage.Response( + requestId = "req-1", + ok = true, + result = buildJsonObject { put("count", 2) }, + ), + WearMessage.Response( + requestId = "req-2", + ok = false, + error = WearRpcError(code = "unavailable", message = "Phone offline"), + ), + WearMessage.Event( + sequence = 7, + event = WearEventType.Chat, + payload = buildJsonObject { put("state", "delta") }, + ), + ) + + messages.forEach { message -> + assertEquals(WearDecodeResult.Success(message), WearProtocolCodec.decode(WearProtocolCodec.encode(message))) + } + } + + @Test + fun usesStableWireNamesAndPaths() { + val methodNames = + mapOf( + WearRpcMethod.ProxyStatus to "proxy.status", + WearRpcMethod.SessionsList to "sessions.list", + WearRpcMethod.ChatHistory to "chat.history", + WearRpcMethod.ChatSend to "chat.send", + WearRpcMethod.ChatAbort to "chat.abort", + ) + methodNames.forEach { (method, wireName) -> + val request = WearMessage.Request(requestId = "req-1", method = method) + val root = Json.parseToJsonElement(WearProtocolCodec.encode(request).decodeToString()).jsonObject + assertEquals("request", root.getValue("type").jsonPrimitive.content) + assertEquals(wireName, root.getValue("method").jsonPrimitive.content) + } + + val eventNames = + mapOf( + WearEventType.Chat to "chat", + WearEventType.Connection to "connection", + ) + eventNames.forEach { (event, wireName) -> + val message = WearMessage.Event(sequence = 1, event = event) + val root = Json.parseToJsonElement(WearProtocolCodec.encode(message).decodeToString()).jsonObject + assertEquals("event", root.getValue("type").jsonPrimitive.content) + assertEquals(wireName, root.getValue("event").jsonPrimitive.content) + } + + assertEquals("/openclaw/wear/v1/request", WearProtocol.REQUEST_PATH) + assertEquals("/openclaw/wear/v1/response", WearProtocol.RESPONSE_PATH) + assertEquals("/openclaw/wear/v1/event", WearProtocol.EVENT_PATH) + } + + @Test + fun ignoresUnknownFieldsWithinCurrentVersion() { + val bytes = + """{"type":"request","version":1,"requestId":"req-1","method":"proxy.status","params":{},"future":true}""" + .encodeToByteArray() + + assertEquals( + WearDecodeResult.Success( + WearMessage.Request(requestId = "req-1", method = WearRpcMethod.ProxyStatus), + ), + WearProtocolCodec.decode(bytes), + ) + } + + @Test + fun rejectsMalformedUnsupportedAndInvalidMessages() { + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Empty), + WearProtocolCodec.decode(byteArrayOf()), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Malformed), + WearProtocolCodec.decode("not-json".encodeToByteArray()), + ) + val invalidUtf8 = + """{"type":"request","version":1,"requestId":"""".encodeToByteArray() + + byteArrayOf(0xc3.toByte(), 0x28) + + """","method":"proxy.status","params":{}}""".encodeToByteArray() + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.Malformed), + WearProtocolCodec.decode(invalidUtf8), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.UnsupportedVersion), + WearProtocolCodec.decode( + """{"type":"future-message","version":2,"futureRequiredField":true}""" + .encodeToByteArray(), + ), + ) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope), + WearProtocolCodec.decode( + """{"type":"response","version":1,"requestId":"req-1","ok":false}""".encodeToByteArray(), + ), + ) + } + + @Test + fun rejectsOversizedMessagesOnEncodeAndDecode() { + val oversizedBytes = ByteArray(WearProtocol.MAX_MESSAGE_BYTES + 1) + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.TooLarge), + WearProtocolCodec.decode(oversizedBytes), + ) + + val oversizedMessage = + WearMessage.Request( + requestId = "req-1", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("message", "x".repeat(WearProtocol.MAX_MESSAGE_BYTES)) }, + ) + assertThrows(IllegalArgumentException::class.java) { + WearProtocolCodec.encode(oversizedMessage) + } + } + + @Test + fun rejectsExcessiveJsonDepthBeforeParsing() { + val nesting = WearProtocol.MAX_JSON_DEPTH + 1 + val deeplyNested = + """{"type":"request","version":1,"requestId":"req-1","method":"chat.send","params":{"payload":${"[".repeat(nesting)}0${"]".repeat(nesting)}}}""" + + assertEquals( + WearDecodeResult.Failure(WearDecodeFailureReason.TooDeep), + WearProtocolCodec.decode(deeplyNested.encodeToByteArray()), + ) + + val bracketsInString = + WearMessage.Request( + requestId = "req-2", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("message", "[".repeat(WearProtocol.MAX_JSON_DEPTH + 1)) }, + ) + assertEquals( + WearDecodeResult.Success(bracketsInString), + WearProtocolCodec.decode(WearProtocolCodec.encode(bracketsInString)), + ) + + var nestedPayload: JsonElement = JsonPrimitive(0) + repeat(4_096) { + nestedPayload = JsonArray(listOf(nestedPayload)) + } + val deeplyNestedMessage = + WearMessage.Request( + requestId = "req-3", + method = WearRpcMethod.ChatSend, + params = buildJsonObject { put("payload", nestedPayload) }, + ) + assertThrows(IllegalArgumentException::class.java) { + WearProtocolCodec.encode(deeplyNestedMessage) + } + } + + @Test + fun encodingIsDeterministic() { + val message = WearMessage.Request(requestId = "req-1", method = WearRpcMethod.SessionsList) + assertArrayEquals(WearProtocolCodec.encode(message), WearProtocolCodec.encode(message)) + } +} diff --git a/package.json b/package.json index 253cb7177121..2d23563cdec5 100644 --- a/package.json +++ b/package.json @@ -1505,14 +1505,14 @@ "./cli-entry": "./openclaw.mjs" }, "scripts": { - "android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug", + "android:assemble": "node scripts/run-android-gradle.mjs :app:assemblePlayDebug :wear-shared:assembleDebug", "android:assemble:third-party": "node scripts/run-android-gradle.mjs :app:assembleThirdPartyDebug", "android:bundle:release": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android play_store_archive'", - "android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat", + "android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat :wear-shared:ktlintFormat", "android:install": "node scripts/run-android-gradle.mjs :app:installPlayDebug", "android:install:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug", - "android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck", - "android:lint:android": "node scripts/run-android-gradle.mjs :app:lintPlayDebug :app:lintThirdPartyDebug", + "android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck :wear-shared:ktlintCheck", + "android:lint:android": "node scripts/run-android-gradle.mjs :app:lintPlayDebug :app:lintThirdPartyDebug :wear-shared:lintDebug", "android:run": "node scripts/run-android-gradle.mjs :app:installPlayDebug -- adb shell am start -n ai.openclaw.app/.MainActivity", "android:run:third-party": "node scripts/run-android-gradle.mjs :app:installThirdPartyDebug -- adb shell am start -n ai.openclaw.app/.MainActivity", "android:release": "bash scripts/android-release.sh", @@ -1526,7 +1526,7 @@ "android:release:signing:sync:push": "bash -lc 'source ./scripts/lib/android-fastlane.sh && cd apps/android && run_android_fastlane android signing_sync_push'", "android:release:upload": "bash scripts/android-release-upload.sh", "android:screenshots": "bash scripts/android-screenshots.sh", - "android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest", + "android:test": "node scripts/run-android-gradle.mjs :app:testPlayDebugUnitTest :wear-shared:testDebugUnitTest", "android:test:integration": "node scripts/run-with-env.mjs OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "android:test:third-party": "node scripts/run-android-gradle.mjs :app:testThirdPartyDebugUnitTest", "android:version": "node --import tsx scripts/android-version.ts --json",