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",