feat(android): add Wear OS companion (#109433)

* feat(android): add Wear OS companion

Co-authored-by: Sebastian Schubotz <git@sibbl.net>

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* style(android): format Wear release gate test

* test(android): cover Wear release CI contracts

* test(android): import Wear JSON fixture type

* fix(android): harden Wear proxy event actor

* test(android): stabilize Wear proxy actor tests

* test(android): isolate Wear proxy actor lifecycles

* test(android): own Wear actor dispatchers

* test(android): use real time for Wear actor tests

* test(android): own Wear actors in test scope

* test(android): stop Wear actors explicitly

* test(android): drain Wear actor cancellation

* test(android): isolate Wear actors from test scheduler

* test(android): inject Wear test clock

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-16 20:59:29 -07:00
committed by GitHub
parent 61bec3a2de
commit 8d5e39afcd
44 changed files with 4778 additions and 125 deletions

5
.github/labeler.yml vendored
View File

@@ -212,6 +212,11 @@
- any-glob-to-any-file:
- "apps/android/**"
- "docs/platforms/android.md"
"app: wearos":
- changed-files:
- any-glob-to-any-file:
- "apps/android/wear/**"
- "apps/android/wear-shared/**"
"app: ios":
- changed-files:
- any-glob-to-any-file:

View File

@@ -701,18 +701,24 @@ jobs:
android_matrix: createMatrix(
runAndroid
? [
// android-ci-contract-v2: both app variants, Android lint, benchmark, and ktlint.
// android-ci-contract-v3: phone variants, Wear modules, Android lint, benchmark, and ktlint.
{
check_name: "android-test-play",
task: useCompatibleAndroidCi ? "test-play-compat" : "test-play",
},
{ check_name: "android-test-third-party", task: "test-third-party" },
...(!useCompatibleAndroidCi
? [{ check_name: "android-test-wear", task: "test-wear" }]
: []),
{
check_name: "android-build-play",
task: useCompatibleAndroidCi ? "build-play-compat" : "build-play",
},
...(!useCompatibleAndroidCi
? [{ check_name: "android-ktlint", task: "ktlint" }]
? [
{ check_name: "android-build-wear", task: "build-wear" },
{ check_name: "android-ktlint", task: "ktlint" },
]
: []),
]
: [],
@@ -3054,6 +3060,9 @@ jobs:
test-third-party)
./gradlew --no-daemon --build-cache :app:testThirdPartyDebugUnitTest
;;
test-wear)
./gradlew --no-daemon --build-cache :wear:testDebugUnitTest
;;
build-play)
./gradlew --no-daemon --build-cache \
:app:assemblePlayDebug \
@@ -3064,6 +3073,11 @@ jobs:
:wear-shared:assembleDebug \
:wear-shared:lintDebug
;;
build-wear)
./gradlew --no-daemon --build-cache \
:wear:assembleDebug \
:wear:lintDebug
;;
build-play-compat)
# Frozen targets keep their target-owned Android build contract. New lint rules
# must not retroactively reject a previously validated release branch.
@@ -3074,6 +3088,7 @@ jobs:
./gradlew --no-daemon --build-cache \
:app:ktlintCheck \
:benchmark:ktlintCheck \
:wear:ktlintCheck \
:wear-shared:ktlintCheck
;;
*)

View File

@@ -6,6 +6,8 @@ Adds foreground, on-device Voice Wake with editable Gateway-synced wake words an
Fixes malformed Android agent and profile initials when display names begin with emoji. Thanks @Leon-SK668.
Adds a Wear OS companion for sessions, transcripts, text and voice replies, abort controls, reply notifications, and a launch Tile. The watch proxies through the paired phone and stores no Gateway credentials. Thanks @sibbl and @IWhatsskill.
## 2026.7.2 - 2026-07-13
Adds Automations and Skills management with search, filters, editing, run tracking, install safety, and ClawHub risk review.

View File

@@ -24,11 +24,23 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
- [x] Skills settings can search installed skills, enable or disable them, and install Gateway-verified ClawHub releases
- [x] Per-app language selection for translated resources follows Android system settings and persistence
- [x] Cron job settings support details, run history, run now, edits, enable/disable, and deletion with admin-scoped Gateway access
- [x] Wear OS companion proxies sessions, transcripts, replies, and aborts through the paired phone without storing Gateway credentials on the watch
## Open in Android Studio
- Open the folder `apps/android`.
## Wear OS companion
The `wear` app is a paired-phone companion with the same application ID and signing identity as the phone app. The watch discovers the phone through Wear OS Data Layer, then uses the phone's existing authenticated operator session. It never receives or stores Gateway tokens, passwords, TLS pins, or device-signing identity.
The watch supports session selection, bounded text-only transcript history, streaming reply state, text and voice replies, abort, local reply notifications, and a launch Tile. A missing Data Layer event sequence or changed phone-process epoch triggers a fresh history request instead of applying uncertain deltas.
```bash
cd apps/android
./gradlew :wear:testDebugUnitTest :wear:assembleDebug :wear:lintDebug :wear:ktlintCheck
```
## Build / Run
```bash
@@ -92,6 +104,7 @@ explicitly use a connected emulator.
`pnpm android:release:archive` builds signed release artifacts into `apps/android/build/release-artifacts/` and writes `.sha256` checksum files:
- Play build: `openclaw-<version>-play-release.aab`
- Wear build: `openclaw-<version>-wear-release.aab`
- Third-party build: `openclaw-<version>-third-party-release.apk`
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
@@ -109,6 +122,11 @@ metadata, signing, validation, archive, or upload step before trying again. Do
not upload archived artifacts through direct Fastlane lanes, Gradle artifacts,
Google Play API commands, or Play Console mutation commands.
The release lane uploads the phone and Wear bundles in one atomic Google Play
edit. It publishes the phone bundle to `GOOGLE_PLAY_TRACK` and maps the Wear
bundle to the corresponding form-factor track (`wear:qa` for the default
internal channel, otherwise `wear:<track>`).
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
Prefer `pnpm android:release:archive`, which stamps and validates the full Git commit and one UTC build timestamp before signing. Flavor-specific direct Gradle release tasks must pass the same metadata explicitly:
@@ -118,6 +136,7 @@ cd apps/android
commit="$(git -C ../.. rev-parse HEAD)"
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundlePlayRelease
./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :wear:bundleRelease
./gradlew -PopenclawBuildCommit="$commit" -PopenclawBuildTimestamp="$built_at" :app:bundleThirdPartyRelease
```

View File

@@ -0,0 +1,19 @@
AndroidX Wear
Artifacts:
- androidx.wear.compose:compose-foundation:1.6.2
- androidx.wear.compose:compose-material3:1.6.2
- androidx.wear:wear-input:1.2.0
- androidx.wear.tiles:tiles:1.6.1
- androidx.wear.protolayout:protolayout:1.4.1
- androidx.wear.protolayout:protolayout-material3:1.4.1
Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0.
You may obtain a copy of the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

View File

@@ -6,7 +6,8 @@ Android release builds use pinned app metadata instead of auto-bumping `build.gr
- `apps/android/version.json` is the source of truth.
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for that pinned app version.
- `versionCode` uses `YYYYMMDDNN`, where phone build number `NN` is `01` through `49`.
- The matching Wear APK reserves `51` through `99` by adding `50` to the pinned phone `versionCode`; Play requires a unique code per form factor under the shared application ID.
- `apps/android/Config/Version.properties` is generated from `version.json` and read by Gradle.
- `apps/android/CHANGELOG.md` is the Android-only changelog and release-note source.
- `apps/android/fastlane/metadata/android/en-US/release_notes.txt` is generated from the changelog.
@@ -15,6 +16,7 @@ Examples:
- `version = 2026.6.2`
- `versionCode = 2026060201`
- matching Wear `versionCode = 2026060251`
- another upload on the same release train: `versionCode = 2026060202`
## Commands
@@ -50,8 +52,8 @@ Recommended workflow:
4. Run `MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull` to materialize encrypted Android signing assets from `apps-signing`.
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `pnpm android:screenshots` to refresh raw Google Play screenshots with the script-managed no-cutout emulator.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to the configured Google Play track.
7. Run `pnpm android:release:archive` to produce the signed phone Play AAB, Wear AAB, and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, the phone AAB, and the Wear AAB to their phone and `wear:` tracks in one atomic Google Play edit.
9. For a regular final or correction OpenClaw release, let `OpenClaw Release Publish` dispatch the protected `Android Release` workflow. It builds the signed third-party APK from the exact tag and attaches the verified APK, checksum manifest, and GitHub provenance before the release draft can publish. Before tagging a correction with its own package version, increment the pinned `versionCode`; the workflow verifies it is higher than the preceding final or correction APK. A same-commit fallback correction reuses the base release's verified APK and adds provenance for the correction tag.
10. Complete production rollout manually in Google Play Console when needed.
@@ -83,7 +85,7 @@ not appear on GitHub release or tag pages, and they do not participate in the
core OpenClaw release machinery.
`pnpm android:release:upload` checks the ref before uploading the Play build and
records it only after `upload_to_play_store` succeeds. Existing refs are
records it only after the atomic phone and Wear Play edit commits. Existing refs are
immutable: the same ref at the same SHA is accepted, while the same ref at a
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
not record it because no Play build is published.

View File

@@ -7,6 +7,7 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import ai.openclaw.app.wear.GoogleWearMessageSender
import ai.openclaw.app.wear.GoogleWearPeerResolver
import ai.openclaw.app.wear.WearProxyBridge
import android.app.Application
import android.content.res.Configuration
@@ -37,6 +38,7 @@ class NodeApp : Application() {
WearProxyBridge(
scope = runtimeScope,
sender = GoogleWearMessageSender(this),
peerResolver = GoogleWearPeerResolver(this),
handleRequest = { request -> ensureBackgroundRuntime().handleWearProxyRequest(request) },
)
}

View File

@@ -7,18 +7,29 @@ import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProtocolCodec
import ai.openclaw.wear.shared.WearRpcError
import android.content.Context
import android.os.SystemClock
import com.google.android.gms.tasks.Task
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import java.util.UUID
import java.util.concurrent.Executor
import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -30,6 +41,10 @@ internal fun interface WearMessageSender {
)
}
internal fun interface WearPeerResolver {
suspend fun reachableWatchNodeIds(): Set<String>
}
internal class GoogleWearMessageSender(
context: Context,
) : WearMessageSender {
@@ -44,99 +59,357 @@ internal class GoogleWearMessageSender(
}
}
internal class GoogleWearPeerResolver(
context: Context,
) : WearPeerResolver {
private val capabilityClient = Wearable.getCapabilityClient(context.applicationContext)
override suspend fun reachableWatchNodeIds(): Set<String> =
capabilityClient
.getCapability(WearProtocol.WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.awaitWearTask()
.nodes
.mapTo(linkedSetOf()) { it.id }
}
internal class WearProxyBridge(
private val scope: CoroutineScope,
private val sender: WearMessageSender,
private val peerResolver: WearPeerResolver = WearPeerResolver { emptySet() },
private val monotonicMillis: () -> Long = SystemClock::elapsedRealtime,
private val handleRequest: suspend (WearMessage.Request) -> WearMessage.Response,
) {
private val peerLock = Any()
private val peers = LinkedHashMap<String, Long>()
private val missingPeers = LinkedHashSet<String>()
private var peerGeneration = 0L
private val eventPublishLock = Any()
private var sequence = 0L
private val nextSequence = AtomicLong()
private val eventStreamId = UUID.randomUUID().toString()
private val overflowLock = Any()
private val pendingTerminalEvents = ArrayDeque<WearMessage.Event>()
private val chatStreamProjector = WearChatStreamProjector()
private val requestPermits = Semaphore(MAX_PENDING_REQUESTS)
private var pendingEventCount = 0
private var resyncRequired = false
private var lastPeerDiscoveryAtMillis: Long? = null
// One bounded consumer preserves event order. DROP_OLDEST becomes a visible sequence
// gap, so the watch can recover from chat.history instead of applying stale deltas.
private val events =
Channel<WearMessage.Event>(
capacity = MAX_BUFFERED_EVENTS,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// Events and requests are bounded before entering this single actor. An overflow
// marker occupies the exact queue position where dropped sequences become visible.
private val operations = Channel<WearBridgeOperation>(capacity = Channel.UNLIMITED)
init {
scope.launch {
for (event in events) sendEvent(event)
var lastDeliveredSequence = 0L
for (operation in operations) {
try {
lastDeliveredSequence = processOperation(operation, lastDeliveredSequence)
} catch (err: CancellationException) {
// A transport implementation may surface cancellation as its failure result. Keep the
// shared actor alive unless its owning scope was actually canceled.
currentCoroutineContext().ensureActive()
}
}
}
}
private suspend fun processOperation(
operation: WearBridgeOperation,
lastDeliveredSequence: Long,
): Long =
when (operation) {
is WearBridgeOperation.Event -> {
markEventDequeued()
sendEventPreservingActor(operation.message)
operation.message.sequence
}
is WearBridgeOperation.Request -> {
try {
val response =
handleRequest(operation.message).copy(
eventStreamId = eventStreamId,
eventSequence = lastDeliveredSequence,
)
operation.completion.complete(
sendResponseToPeer(operation.sourcePeer, encodeResponse(response)),
)
} catch (err: Throwable) {
operation.completion.completeExceptionally(err)
// A canceled request or Play Services task must not kill the process actor.
// Parent-scope cancellation still propagates and terminates the bridge.
if (err is CancellationException) currentCoroutineContext().ensureActive()
}
lastDeliveredSequence
}
WearBridgeOperation.Overflow -> {
val overflow = takeOverflow()
var deliveredSequence = lastDeliveredSequence
for (terminal in overflow.terminalEvents.sortedBy { it.sequence }) {
sendEventPreservingActor(terminal)
deliveredSequence = terminal.sequence
}
overflow.resyncEvent?.let { resync ->
sendEventPreservingActor(resync)
deliveredSequence = resync.sequence
}
deliveredSequence
}
is WearBridgeOperation.Barrier -> {
operation.completion.complete(Unit)
lastDeliveredSequence
}
}
suspend fun handleMessage(
sourceNodeId: String,
data: ByteArray,
) {
if (sourceNodeId.isBlank()) return
): Boolean {
if (sourceNodeId.isBlank()) return false
val request =
(WearProtocolCodec.decode(data) as? WearDecodeResult.Success)
?.message as? WearMessage.Request ?: return
?.message as? WearMessage.Request ?: return false
val peer = rememberPeer(sourceNodeId)
val response = handleRequest(request)
val encoded = encodeResponse(response)
sendToPeer(peer, WearProtocol.RESPONSE_PATH, encoded)
return requestPermits.withPermit {
val completion = CompletableDeferred<Boolean>()
operations.send(WearBridgeOperation.Request(peer, request, completion))
completion.await()
}
}
fun publishConnection(
connected: Boolean,
status: String,
) {
publishEvent(
WearEventType.Connection,
buildJsonObject {
put("connected", connected)
put("status", status)
},
)
}
fun publishChat(payload: JsonElement) {
projectWearChatEvent(payload)?.let { publishEvent(WearEventType.Chat, it) }
}
private fun publishEvent(
type: WearEventType,
payload: JsonElement,
) {
if (!hasPeers()) return
// Sequence allocation and channel insertion are one ordered operation. Splitting them lets
// concurrent connection/chat callbacks enqueue N+1 before N and manufacture a false gap.
synchronized(eventPublishLock) {
sequence += 1
events.trySend(
WearMessage.Event(
sequence = sequence,
event = type,
payload = payload,
),
synchronized(overflowLock) {
if (!connected) chatStreamProjector.reset()
publishEventLocked(
WearEventType.Connection,
buildJsonObject {
put("connected", connected)
put("status", status)
},
)
}
}
fun publishChat(payload: JsonElement) {
synchronized(overflowLock) {
chatStreamProjector.project(payload)?.let { projected ->
val state = (projected["state"] as? JsonPrimitive)?.contentOrNull
publishEventLocked(
type = WearEventType.Chat,
payload = projected,
terminal = state == "final" || state == "aborted" || state == "error",
)
}
}
}
/** Test-only actor barrier; proves every operation queued before this call has settled. */
internal suspend fun awaitIdleForTests() {
val completion = CompletableDeferred<Unit>()
operations.send(WearBridgeOperation.Barrier(completion))
completion.await()
}
/** Projection/reset, sequence allocation, and actor insertion share [overflowLock]. */
private fun publishEventLocked(
type: WearEventType,
payload: JsonElement,
terminal: Boolean = false,
) {
val event =
WearMessage.Event(
streamId = eventStreamId,
sequence = nextSequence.incrementAndGet(),
event = type,
payload = payload,
)
if (resyncRequired || pendingEventCount >= MAX_BUFFERED_EVENTS) {
if (!resyncRequired) {
resyncRequired = true
operations.trySend(WearBridgeOperation.Overflow).getOrThrow()
}
resyncRequired = true
if (terminal) {
if (pendingTerminalEvents.size == MAX_PENDING_TERMINAL_EVENTS) {
pendingTerminalEvents.removeFirst()
}
pendingTerminalEvents.addLast(event)
}
} else {
pendingEventCount += 1
operations.trySend(WearBridgeOperation.Event(event)).getOrThrow()
}
}
private fun markEventDequeued() {
synchronized(overflowLock) {
check(pendingEventCount > 0)
pendingEventCount -= 1
}
}
private fun takeOverflow(): WearOverflowSnapshot =
synchronized(overflowLock) {
val resyncEvent =
if (resyncRequired) {
WearMessage.Event(
streamId = eventStreamId,
sequence = nextSequence.incrementAndGet(),
event = WearEventType.Resync,
)
} else {
null
}
WearOverflowSnapshot(
terminalEvents = pendingTerminalEvents.toList(),
resyncEvent = resyncEvent,
).also {
pendingTerminalEvents.clear()
resyncRequired = false
}
}
private sealed interface WearBridgeOperation {
data class Request(
val sourcePeer: PeerRegistration,
val message: WearMessage.Request,
val completion: CompletableDeferred<Boolean>,
) : WearBridgeOperation
data class Event(
val message: WearMessage.Event,
) : WearBridgeOperation
data object Overflow : WearBridgeOperation
data class Barrier(
val completion: CompletableDeferred<Unit>,
) : WearBridgeOperation
}
private data class WearOverflowSnapshot(
val terminalEvents: List<WearMessage.Event>,
val resyncEvent: WearMessage.Event?,
)
private suspend fun sendEvent(event: WearMessage.Event) {
val encoded = runCatching { WearProtocolCodec.encode(event) }.getOrNull() ?: return
for (peer in peerSnapshot()) {
sendToPeer(peer, WearProtocol.EVENT_PATH, encoded)
val terminalChatEvent = event.isTerminalChatEvent()
// A terminal reply may need to notify a watch that has not contacted this phone
// process yet, even while another remembered watch remains healthy.
discoverPeers(forceRefresh = terminalChatEvent, bypassNegativeCache = terminalChatEvent)
val initialPeers = peerSnapshot()
if (initialPeers.isEmpty()) return
val initialResult = sendToPeers(initialPeers, encoded)
if (initialResult.failed.isEmpty()) return
// Refresh after any stale peer, but do not redeliver the sequence to watches
// that already accepted it. Newly reachable and recovered peers get one retry.
discoverPeers(forceRefresh = true, bypassNegativeCache = true)
val retryPeers = peerSnapshot().filterNot { it.nodeId in initialResult.delivered }
sendToPeers(retryPeers, encoded)
}
private suspend fun sendEventPreservingActor(event: WearMessage.Event) {
try {
sendEvent(event)
} catch (err: CancellationException) {
// Play Services may cancel one transport Task without canceling this actor.
// Only parent-scope cancellation should terminate the shared event/request loop.
currentCoroutineContext().ensureActive()
}
}
private fun WearMessage.Event.isTerminalChatEvent(): Boolean {
if (event != WearEventType.Chat) return false
val state = ((payload as? JsonObject)?.get("state") as? JsonPrimitive)?.contentOrNull
return state == "final" || state == "aborted" || state == "error"
}
private suspend fun sendToPeers(
peers: List<PeerRegistration>,
data: ByteArray,
): WearPeerSendResult {
val delivered = linkedSetOf<String>()
val failed = linkedSetOf<String>()
for (peer in peers) {
if (sendToPeer(peer, WearProtocol.EVENT_PATH, data)) {
delivered += peer.nodeId
} else {
failed += peer.nodeId
}
}
return WearPeerSendResult(delivered = delivered, failed = failed)
}
private suspend fun sendResponseToPeer(
peer: PeerRegistration,
data: ByteArray,
): Boolean {
if (sendToPeer(peer, WearProtocol.RESPONSE_PATH, data)) return true
// The request proves this exact node was reachable moments ago. Retry one
// transport failure directly; a successful retry restores it to the peer set.
if (!sendToPeer(peer, WearProtocol.RESPONSE_PATH, data)) return false
rememberPeer(peer.nodeId)
return true
}
private suspend fun discoverPeers(
forceRefresh: Boolean,
bypassNegativeCache: Boolean,
) {
if (!forceRefresh && hasPeers() && !needsPeerRefresh()) return
val now = monotonicMillis()
val previousDiscovery = lastPeerDiscoveryAtMillis
if (
!bypassNegativeCache &&
previousDiscovery != null &&
now >= previousDiscovery &&
now - previousDiscovery < PEER_DISCOVERY_RETRY_MILLIS
) {
return
}
lastPeerDiscoveryAtMillis = now
val resolvedPeers = resolvePeers()
resolvedPeers.sorted().take(MAX_PEERS).forEach(::rememberPeer)
}
private suspend fun resolvePeers(): Set<String> {
repeat(2) { attempt ->
try {
return peerResolver.reachableWatchNodeIds()
} catch (_: WearTaskCanceledException) {
if (attempt == 1) return emptySet()
} catch (err: CancellationException) {
// A custom resolver may use cancellation as a transient discovery failure.
// Preserve parent cancellation and retry this event once.
currentCoroutineContext().ensureActive()
if (attempt == 1) return emptySet()
} catch (_: Throwable) {
return emptySet()
}
}
return emptySet()
}
private suspend fun sendToPeer(
peer: PeerRegistration,
path: String,
data: ByteArray,
) {
): Boolean {
try {
sender.send(peer.nodeId, path, data)
return true
} catch (err: CancellationException) {
throw err
// Custom senders may use cancellation for transport failure. Keep the actor
// cancellation contract while retrying this peer normally.
currentCoroutineContext().ensureActive()
markPeerFailed(peer)
return false
} catch (_: Throwable) {
forgetPeer(peer)
markPeerFailed(peer)
return false
}
}
@@ -148,6 +421,8 @@ internal class WearProxyBridge(
requestId = response.requestId,
ok = false,
error = WearRpcError(code = "response_too_large", message = "Phone response exceeds Wear transport limits"),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
),
)
}
@@ -155,6 +430,7 @@ internal class WearProxyBridge(
private fun rememberPeer(nodeId: String): PeerRegistration =
synchronized(peerLock) {
peerGeneration += 1
missingPeers.remove(nodeId)
peers.remove(nodeId)
peers[nodeId] = peerGeneration
while (peers.size > MAX_PEERS) {
@@ -163,16 +439,21 @@ internal class WearProxyBridge(
PeerRegistration(nodeId = nodeId, generation = peerGeneration)
}
private fun forgetPeer(peer: PeerRegistration) {
private fun markPeerFailed(peer: PeerRegistration) {
synchronized(peerLock) {
// An older event send may fail after a new request refreshed the same watch. Remove only
// the registration that owned this send, or the successful request would lose its events.
if (peers[peer.nodeId] == peer.generation) peers.remove(peer.nodeId)
// A stale send may fail after a newer request refreshed the same watch.
// Remove only the registration that actually owned this transport attempt.
if (peers[peer.nodeId] == peer.generation) {
peers.remove(peer.nodeId)
missingPeers.add(peer.nodeId)
}
}
}
private fun hasPeers(): Boolean = synchronized(peerLock) { peers.isNotEmpty() }
private fun needsPeerRefresh(): Boolean = synchronized(peerLock) { missingPeers.isNotEmpty() }
private fun peerSnapshot(): List<PeerRegistration> =
synchronized(peerLock) {
peers.map { (nodeId, generation) -> PeerRegistration(nodeId = nodeId, generation = generation) }
@@ -180,9 +461,17 @@ internal class WearProxyBridge(
internal fun peerCountForTests(): Int = synchronized(peerLock) { peers.size }
private data class WearPeerSendResult(
val delivered: Set<String>,
val failed: Set<String>,
)
private companion object {
const val MAX_PEERS = 8
const val MAX_BUFFERED_EVENTS = 32
const val MAX_PENDING_REQUESTS = 8
const val MAX_PENDING_TERMINAL_EVENTS = 8
const val PEER_DISCOVERY_RETRY_MILLIS = 30_000L
}
}
@@ -191,6 +480,106 @@ private data class PeerRegistration(
val generation: Long,
)
internal class WearChatStreamProjector {
private data class StreamKey(
val sessionKey: String,
val runId: String?,
)
private data class StreamState(
val text: String,
val complete: Boolean,
)
private val streams = LinkedHashMap<StreamKey, StreamState>()
@Synchronized
fun reset() {
streams.clear()
}
@Synchronized
fun project(payload: JsonElement): JsonObject? {
val projected = projectWearChatEvent(payload) ?: return null
val state = (projected["state"] as? JsonPrimitive)?.contentOrNull
val streamKey = streamKey(projected)
if (state != "delta") {
if (state == "final" || state == "aborted" || state == "error") {
streamKey?.let { terminalKey ->
streams.keys.removeAll { key -> key.sessionKey == terminalKey.sessionKey }
}
}
return projected
}
val delta = (projected["deltaText"] as? JsonPrimitive)?.contentOrNull.orEmpty()
val replace = (projected["replace"] as? JsonPrimitive)?.contentOrNull == "true"
val fullMessage = projectedWearMessageText(projected["message"])
if (streamKey == null && fullMessage == null && !replace) {
// Events without a session cannot affect a selected watch transcript.
return projected
}
val stateKey =
streamKey?.let { key ->
if (key.runId != null) {
key
} else {
// A gateway may omit runId on any delta, not only at run start.
// Keep such deltas on the session's active identified accumulator.
streams.keys.lastOrNull { candidate -> candidate.sessionKey == key.sessionKey && candidate.runId != null } ?: key
}
}
val previous =
stateKey?.let { key ->
streams[key]
?: key.runId?.let {
// Some gateways reveal runId after initial anonymous deltas. Adopt
// that accumulator so the identified stream keeps its prefix.
streams.remove(StreamKey(sessionKey = key.sessionKey, runId = null))
}
}
val next =
when {
fullMessage != null -> StreamState(fullMessage, complete = true)
replace -> StreamState(delta, complete = true)
previous != null -> StreamState(previous.text + delta, complete = previous.complete)
else -> StreamState(delta, complete = false)
}.bounded()
if (stateKey != null) {
streams.remove(stateKey)
streams[stateKey] = next
while (streams.size > MAX_STREAMS) streams.remove(streams.keys.first())
}
return buildJsonObject {
projected.forEach { (key, value) -> put(key, value) }
put("streamText", next.text)
put("streamTextComplete", next.complete)
}
}
private fun streamKey(projected: JsonObject): StreamKey? {
val sessionKey =
(projected["sessionKey"] as? JsonPrimitive)
?.contentOrNull
?.takeIf { it.isNotBlank() } ?: return null
val runId = (projected["runId"] as? JsonPrimitive)?.contentOrNull
// Some gateway deltas omit runId. Sessions serialize active runs, and every
// terminal event clears all keys for that session before another run starts.
return StreamKey(sessionKey = sessionKey, runId = runId)
}
private fun StreamState.bounded(): StreamState {
val count = text.codePointCount(0, text.length)
if (count <= MAX_STREAM_CODE_POINTS) return this
val start = text.offsetByCodePoints(0, count - MAX_STREAM_CODE_POINTS)
return copy(text = text.substring(start))
}
private companion object {
const val MAX_STREAMS = 32
const val MAX_STREAM_CODE_POINTS = 2_000
}
}
internal class WearTaskCanceledException : IllegalStateException("Wear message task was canceled")
private val directTaskExecutor = Executor(Runnable::run)
@@ -198,15 +587,12 @@ private val directTaskExecutor = Executor(Runnable::run)
internal suspend fun <T> Task<T>.awaitWearTask(): T =
suspendCancellableCoroutine { continuation ->
addOnSuccessListener(directTaskExecutor) { value ->
continuation.resume(value)
if (continuation.isActive) continuation.resume(value)
}
addOnFailureListener(directTaskExecutor) { error ->
continuation.resumeWithException(error)
if (continuation.isActive) continuation.resumeWithException(error)
}
// Google Tasks do not invoke failure listeners for cancellation. Treat the Task's canceled
// state as a send failure; cancellation of the calling coroutine remains owned by the
// cancellable continuation and is still propagated as CancellationException.
addOnCanceledListener(directTaskExecutor) {
continuation.resumeWithException(WearTaskCanceledException())
if (continuation.isActive) continuation.resumeWithException(WearTaskCanceledException())
}
}

View File

@@ -173,6 +173,24 @@ internal fun projectWearChatEvent(payload: JsonElement): JsonObject? {
}
}
internal fun projectedWearMessageText(message: JsonElement?): String? {
val content = (message as? JsonObject)?.get("content")
val text =
when (content) {
is JsonPrimitive -> content.contentOrNull
is JsonArray ->
content.joinToString(separator = "") { part ->
when (part) {
is JsonPrimitive -> part.contentOrNull.orEmpty()
is JsonObject -> part.stringOrNull("text").orEmpty()
else -> ""
}
}
else -> null
}
return text?.takeIf { it.isNotEmpty() }
}
private fun projectHistory(source: JsonObject): JsonObject =
buildJsonObject {
copyString(source, "sessionKey", MAX_SESSION_KEY_CHARS)

View File

@@ -37,6 +37,7 @@ class AndroidLicenseNoticesTest {
assertEquals(
listOf(
"AndroidX Room",
"AndroidX Wear",
"Bouncy Castle Provider",
"Coil",
"CommonMark Java",

View File

@@ -10,10 +10,18 @@ import com.google.android.gms.tasks.TaskCompletionSource
import com.google.android.gms.tasks.Tasks
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -24,6 +32,23 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WearProxyBridgeTest {
private fun withActorScope(block: suspend CoroutineScope.(CoroutineScope) -> Unit) =
runBlocking {
withTimeout(ACTOR_TEST_TIMEOUT_MILLIS) {
val actorJob = Job(coroutineContext[Job])
val actorScope = CoroutineScope(actorJob + Dispatchers.Default)
try {
block(actorScope)
} finally {
actorJob.cancelAndJoin()
}
}
}
private companion object {
const val ACTOR_TEST_TIMEOUT_MILLIS = 30_000L
}
@Test
fun validRequestRegistersPeerAndReturnsCorrelatedResponse() =
runTest {
@@ -49,6 +74,8 @@ class WearProxyBridgeTest {
assertEquals(WearProtocol.RESPONSE_PATH, sent.single().path)
val response = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Response
assertEquals("req-1", response.requestId)
assertTrue(!response.eventStreamId.isNullOrBlank())
assertEquals(0L, response.eventSequence)
}
@Test
@@ -69,47 +96,563 @@ class WearProxyBridgeTest {
}
@Test
fun responseSendPreservesCancellation() =
fun responseSendCancellationRetriesWithoutTerminatingActor() =
runTest {
var cancelFirstResponse = true
val sent = mutableListOf<SentWearMessage>()
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { _, _, _ -> throw CancellationException("stopping") },
sender =
WearMessageSender { nodeId, path, data ->
if (cancelFirstResponse) {
cancelFirstResponse = false
throw CancellationException("request canceled")
}
sent += SentWearMessage(nodeId, path, data)
},
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
var cancelled = false
try {
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))
} catch (_: CancellationException) {
cancelled = true
assertTrue(bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))))
assertEquals(1, bridge.peerCountForTests())
assertTrue(bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2"))))
assertEquals(listOf(WearProtocol.RESPONSE_PATH, WearProtocol.RESPONSE_PATH), sent.map { it.path })
}
@Test
fun eventSendCancellationDoesNotTerminateRequestActor() =
withActorScope { actorScope ->
var cancelFirstEvent = true
val sent = mutableListOf<SentWearMessage>()
val bridge =
WearProxyBridge(
scope = actorScope,
sender =
WearMessageSender { nodeId, path, data ->
if (path == WearProtocol.EVENT_PATH && cancelFirstEvent) {
cancelFirstEvent = false
throw CancellationException("event canceled")
}
sent += SentWearMessage(nodeId, path, data)
},
peerResolver = WearPeerResolver { setOf("watch-1") },
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.publishConnection(connected = true, status = "Connected")
bridge.awaitIdleForTests()
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))
assertEquals(listOf(WearProtocol.EVENT_PATH, WearProtocol.RESPONSE_PATH), sent.map { it.path })
}
@Test
fun discoveryTaskCancellationDoesNotTerminateEventActor() =
withActorScope { actorScope ->
var cancelFirstDiscovery = true
val sent = mutableListOf<SentWearMessage>()
val bridge =
WearProxyBridge(
scope = actorScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
peerResolver =
WearPeerResolver {
if (cancelFirstDiscovery) {
cancelFirstDiscovery = false
throw CancellationException("discovery canceled")
}
setOf("watch-1")
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.publishConnection(connected = true, status = "first")
bridge.publishConnection(connected = true, status = "second")
bridge.awaitIdleForTests()
assertEquals(listOf(WearProtocol.EVENT_PATH, WearProtocol.EVENT_PATH), sent.map { it.path })
}
@Test
fun historyResponseWatermarkExcludesEventsQueuedBehindIt() =
runTest {
val sent = mutableListOf<SentWearMessage>()
val requestStarted = CompletableDeferred<Unit>()
val finishRequest = CompletableDeferred<Unit>()
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
handleRequest = { request ->
requestStarted.complete(Unit)
finishRequest.await()
WearMessage.Response(requestId = request.requestId, ok = true)
},
)
val requestJob = async { bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) }
runCurrent()
requestStarted.await()
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "new")
},
)
runCurrent()
assertTrue(sent.isEmpty())
finishRequest.complete(Unit)
requestJob.await()
runCurrent()
assertEquals(listOf(WearProtocol.RESPONSE_PATH, WearProtocol.EVENT_PATH), sent.map { it.path })
val response = (WearProtocolCodec.decode(sent[0].data) as WearDecodeResult.Success).message as WearMessage.Response
val event = (WearProtocolCodec.decode(sent[1].data) as WearDecodeResult.Success).message as WearMessage.Event
assertEquals(response.eventStreamId, event.streamId)
assertEquals(0L, response.eventSequence)
assertEquals(1L, event.sequence)
}
@Test
fun chatStreamProjectionCarriesCanonicalTextAndFallbackCompleteness() {
val projector = WearChatStreamProjector()
val first =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("runId", "run-1")
put("state", "delta")
put("deltaText", "Hel")
put(
"message",
buildJsonObject {
put("role", "assistant")
put("content", "Hel")
},
)
},
),
)
val continued =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("runId", "run-1")
put("state", "delta")
put("deltaText", "lo")
},
),
)
val unknownPrefix =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("runId", "run-2")
put("state", "delta")
put("deltaText", "tail")
},
),
)
assertEquals("Hel", first.getValue("streamText").jsonPrimitive.content)
assertEquals("true", first.getValue("streamTextComplete").jsonPrimitive.content)
assertEquals("Hello", continued.getValue("streamText").jsonPrimitive.content)
assertEquals("true", continued.getValue("streamTextComplete").jsonPrimitive.content)
assertEquals("tail", unknownPrefix.getValue("streamText").jsonPrimitive.content)
assertEquals("false", unknownPrefix.getValue("streamTextComplete").jsonPrimitive.content)
}
@Test
fun runIdLessDeltasUseSessionSnapshotAndKeepExactAppendSemantics() {
val projector = WearChatStreamProjector()
val first =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "a")
},
),
)
val second =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "a")
},
),
)
val identified =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("runId", "run-now-known")
put("state", "delta")
put("deltaText", "a")
},
),
)
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("runId", "run-now-known")
put("state", "final")
},
),
)
val afterFinal =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "a")
},
),
)
assertEquals("a", first.getValue("streamText").jsonPrimitive.content)
assertEquals("aa", second.getValue("streamText").jsonPrimitive.content)
assertEquals("aaa", identified.getValue("streamText").jsonPrimitive.content)
assertEquals("a", afterFinal.getValue("streamText").jsonPrimitive.content)
}
@Test
fun runIdLessDeltaContinuesTheActiveIdentifiedStream() {
val projector = WearChatStreamProjector()
fun delta(
text: String,
runId: String? = null,
): JsonObject =
buildJsonObject {
put("sessionKey", "main")
runId?.let { put("runId", it) }
put("state", "delta")
put("deltaText", text)
}
assertTrue(cancelled)
assertEquals(1, bridge.peerCountForTests())
checkNotNull(projector.project(delta("H", runId = "run-1")))
val anonymous = checkNotNull(projector.project(delta("i")))
val identified = checkNotNull(projector.project(delta("!", runId = "run-1")))
assertEquals("Hi", anonymous.getValue("streamText").jsonPrimitive.content)
assertEquals("Hi!", identified.getValue("streamText").jsonPrimitive.content)
}
@Test
fun connectionResetClearsRunIdLessStreamState() {
val projector = WearChatStreamProjector()
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "stale")
},
),
)
projector.reset()
val next =
checkNotNull(
projector.project(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "fresh")
},
),
)
assertEquals("fresh", next.getValue("streamText").jsonPrimitive.content)
}
@Test
fun coldBridgeDiscoversReachableWatchBeforeBackgroundEvent() =
runTest {
val sent = mutableListOf<SentWearMessage>()
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
peerResolver = WearPeerResolver { setOf("watch-1") },
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "final")
},
)
runCurrent()
assertEquals("watch-1", sent.single().nodeId)
assertEquals(WearProtocol.EVENT_PATH, sent.single().path)
}
@Test
fun canceledGoogleTaskResumesAsSendFailure() =
fun noWatchDiscoveryIsRateLimitedAcrossStreamEvents() =
runTest {
val failure = runCatching { Tasks.forCanceled<Int>().awaitWearTask() }.exceptionOrNull()
var resolutions = 0
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { _, _, _ -> error("must not send") },
peerResolver =
WearPeerResolver {
resolutions += 1
emptySet()
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
assertTrue(failure is WearTaskCanceledException)
repeat(20) { index ->
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "$index")
},
)
}
runCurrent()
assertEquals(1, resolutions)
}
@Test
fun callerCancellationWinsLaterGoogleTaskCompletion() =
fun terminalEventBypassesCachedEmptyPeerDiscovery() =
runTest {
val source = TaskCompletionSource<Int>()
val awaiting = backgroundScope.async { source.task.awaitWearTask() }
val sent = mutableListOf<SentWearMessage>()
var resolutions = 0
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
peerResolver =
WearPeerResolver {
resolutions += 1
if (resolutions == 1) emptySet() else setOf("watch-1")
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "working")
},
)
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "final")
},
)
runCurrent()
awaiting.cancel()
runCurrent()
source.setResult(1)
assertEquals(2, resolutions)
assertEquals("watch-1", sent.single().nodeId)
val event = (WearProtocolCodec.decode(sent.single().data) as WearDecodeResult.Success).message as WearMessage.Event
assertEquals(2L, event.sequence)
}
@Test
fun terminalEventDiscoversAnotherWatchWhileRememberedPeerIsHealthy() =
runTest {
val sent = mutableListOf<SentWearMessage>()
var resolutions = 0
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
peerResolver =
WearPeerResolver {
resolutions += 1
setOf("watch-1", "watch-2")
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))
sent.clear()
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "final")
},
)
bridge.awaitIdleForTests()
assertEquals(1, resolutions)
assertEquals(listOf("watch-1", "watch-2"), sent.map { it.nodeId })
assertTrue(sent.all { it.path == WearProtocol.EVENT_PATH })
}
@Test
fun staleRememberedPeerTriggersDiscoveryAndCurrentEventRetry() =
runTest {
val attempts = mutableListOf<SentWearMessage>()
var resolutions = 0
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender =
WearMessageSender { nodeId, path, data ->
attempts += SentWearMessage(nodeId, path, data)
if (nodeId == "watch-stale" && path == WearProtocol.EVENT_PATH) {
error("stale node")
}
},
peerResolver =
WearPeerResolver {
resolutions += 1
setOf("watch-current")
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.handleMessage("watch-stale", WearProtocolCodec.encode(request("req-1")))
attempts.clear()
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "final")
},
)
bridge.awaitIdleForTests()
// Terminal delivery refreshes before sending; the stale failure then forces a second
// discovery so a watch that appeared during that send still receives the terminal state.
assertEquals(2, resolutions)
assertEquals(listOf("watch-stale", "watch-current"), attempts.map { it.nodeId })
assertTrue(attempts.all { it.path == WearProtocol.EVENT_PATH })
}
@Test
fun partialPeerFailureRediscoversAndRetriesOnlyTheFailedWatch() =
runTest {
val attempts = mutableListOf<String>()
var failSecondWatch = true
var resolutions = 0
val bridge =
WearProxyBridge(
scope = backgroundScope,
sender =
WearMessageSender { nodeId, path, _ ->
if (path != WearProtocol.EVENT_PATH) return@WearMessageSender
attempts += nodeId
if (nodeId == "watch-2" && failSecondWatch) {
failSecondWatch = false
error("transient")
}
},
peerResolver =
WearPeerResolver {
resolutions += 1
setOf("watch-1", "watch-2")
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))
bridge.handleMessage("watch-2", WearProtocolCodec.encode(request("req-2")))
attempts.clear()
bridge.publishConnection(connected = true, status = "Connected")
runCurrent()
assertTrue(awaiting.isCancelled)
assertEquals(1, resolutions)
assertEquals(listOf("watch-1", "watch-2", "watch-2"), attempts)
}
@Test
fun queueOverflowRetainsTerminalEventAndEmitsResync() =
withActorScope { actorScope ->
val sent = mutableListOf<SentWearMessage>()
val requestStarted = CompletableDeferred<Unit>()
val finishRequest = CompletableDeferred<Unit>()
val bridge =
WearProxyBridge(
scope = actorScope,
sender = WearMessageSender { nodeId, path, data -> sent += SentWearMessage(nodeId, path, data) },
monotonicMillis = { 1_000L },
handleRequest = { request ->
requestStarted.complete(Unit)
finishRequest.await()
WearMessage.Response(requestId = request.requestId, ok = true)
},
)
val requestJob = async { bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1"))) }
requestStarted.await()
repeat(40) { index ->
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "delta")
put("deltaText", "$index")
},
)
}
bridge.publishChat(
buildJsonObject {
put("sessionKey", "main")
put("state", "final")
put(
"message",
buildJsonObject {
put("role", "assistant")
put("content", "done")
},
)
},
)
finishRequest.complete(Unit)
requestJob.await()
bridge.awaitIdleForTests()
val events =
sent
.filter { it.path == WearProtocol.EVENT_PATH }
.map { (WearProtocolCodec.decode(it.data) as WearDecodeResult.Success).message as WearMessage.Event }
assertTrue(events.any { it.event == WearEventType.Resync })
assertTrue(
events.any { event ->
event.event == WearEventType.Chat &&
event.payload
?.jsonObject
?.get("state")
?.jsonPrimitive
?.content == "final"
},
)
assertEquals(events.map { it.sequence }.sorted(), events.map { it.sequence })
assertEquals(WearEventType.Resync, events.last().event)
}
@Test
@@ -148,20 +691,47 @@ class WearProxyBridgeTest {
assertEquals(setOf(WearEventType.Connection, WearEventType.Chat), events.map { it.event }.toSet())
val chat = events.first { it.event == WearEventType.Chat }
val payload = checkNotNull(chat.payload).jsonObject
assertEquals(setOf("runId", "sessionKey", "seq", "state", "deltaText"), payload.keys)
assertEquals(
setOf("runId", "sessionKey", "seq", "state", "deltaText", "streamText", "streamTextComplete"),
payload.keys,
)
assertEquals("hello", payload.getValue("deltaText").jsonPrimitive.content)
assertEquals("hello", payload.getValue("streamText").jsonPrimitive.content)
}
@Test
fun canceledGoogleTaskResumesAsSendFailure() =
runTest {
val failure = runCatching { Tasks.forCanceled<Int>().awaitWearTask() }.exceptionOrNull()
assertTrue(failure is WearTaskCanceledException)
}
@Test
fun callerCancellationWinsLaterGoogleTaskCompletion() =
runTest {
val source = TaskCompletionSource<Int>()
val awaiting = backgroundScope.async { source.task.awaitWearTask() }
runCurrent()
awaiting.cancel()
runCurrent()
source.setResult(1)
runCurrent()
assertTrue(awaiting.isCancelled)
}
@Test
fun staleEventFailureDoesNotRemoveRefreshedPeer() =
runTest {
withActorScope { actorScope ->
val eventStarted = CompletableDeferred<Unit>()
val releaseEvent = CompletableDeferred<Unit>()
val sent = mutableListOf<SentWearMessage>()
var failFirstEvent = true
val bridge =
WearProxyBridge(
scope = backgroundScope,
scope = actorScope,
sender =
WearMessageSender { nodeId, path, data ->
if (path == WearProtocol.EVENT_PATH && failFirstEvent) {
@@ -172,21 +742,27 @@ class WearProxyBridgeTest {
}
sent += SentWearMessage(nodeId, path, data)
},
monotonicMillis = { 1_000L },
handleRequest = { request -> WearMessage.Response(requestId = request.requestId, ok = true) },
)
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-1")))
bridge.publishConnection(connected = true, status = "Connected")
eventStarted.await()
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2")))
val refreshed =
async(start = CoroutineStart.UNDISPATCHED) {
bridge.handleMessage("watch-1", WearProtocolCodec.encode(request("req-2")))
}
releaseEvent.complete(Unit)
runCurrent()
assertTrue(refreshed.await())
bridge.awaitIdleForTests()
bridge.publishConnection(connected = false, status = "Offline")
runCurrent()
bridge.awaitIdleForTests()
assertEquals(1, bridge.peerCountForTests())
assertEquals(1, sent.count { it.path == WearProtocol.EVENT_PATH })
// The stale send is retried after discovery, then the later offline event also delivers.
assertEquals(2, sent.count { it.path == WearProtocol.EVENT_PATH })
}
private fun request(requestId: String): WearMessage.Request = WearMessage.Request(requestId = requestId, method = WearRpcMethod.ProxyStatus)

View File

@@ -2,7 +2,7 @@ require "fileutils"
require "json"
require "open3"
require "shellwords"
require "supply/client"
require "supply"
default_platform(:android)
@@ -65,6 +65,11 @@ def play_track
raw.empty? ? DEFAULT_PLAY_TRACK : raw
end
def wear_play_track
default_track = play_track == "internal" ? "qa" : play_track
"wear:#{default_track}"
end
def play_release_status
raw = ENV["GOOGLE_PLAY_RELEASE_STATUS"].to_s.strip
raw.empty? ? DEFAULT_PLAY_RELEASE_STATUS : raw
@@ -161,13 +166,155 @@ def android_changelog_path(version_code)
File.join(ANDROID_FASTLANE_ROOT, "metadata", "android", "en-US", "changelogs", "#{version_code}.txt")
end
def wear_version_code(phone_version_code)
build_number = phone_version_code % 100
UI.user_error!("Android phone versionCode build number must be 01 through 49.") unless (1..49).cover?(build_number)
phone_version_code + 50
end
def sync_android_changelog!(version_code)
validate_android_release_notes!
changelog_path = android_changelog_path(version_code)
FileUtils.mkdir_p(File.dirname(changelog_path))
File.write(changelog_path, File.read(android_release_notes_path))
changelog_path
wear_changelog_path = android_changelog_path(wear_version_code(version_code))
File.write(wear_changelog_path, File.read(android_release_notes_path))
[changelog_path, wear_changelog_path]
end
def play_metadata_languages
Dir.children(play_metadata_path)
.select { |language| File.directory?(File.join(play_metadata_path, language)) }
.reject { |language| language.start_with?(".") }
.sort
end
def play_release_notes(version_code)
play_metadata_languages.filter_map do |language|
changelog_path = File.join(play_metadata_path, language, "changelogs", "#{version_code}.txt")
fallback_path = File.join(play_metadata_path, language, "changelogs", "default.txt")
source_path = File.exist?(changelog_path) ? changelog_path : fallback_path
next unless File.exist?(source_path)
AndroidPublisher::LocalizedText.new(language: language, text: File.read(source_path, encoding: "UTF-8"))
end
end
def update_play_track!(client, track_name:, version_code:, version_name:)
release = AndroidPublisher::TrackRelease.new(
name: version_name,
status: play_release_status,
version_codes: [version_code],
release_notes: play_release_notes(version_code)
)
track = client.tracks(track_name).first || AndroidPublisher::Track.new(track: track_name)
# Google preserves older releases when the edit replaces this list with the newest release.
track.releases = [release]
# Supply::Client owns the active edit; its public API takes (track_name, track_object).
client.update_track(track_name, track)
end
def upload_play_listing_assets!(client, upload_metadata:, upload_images:, upload_screenshots:)
play_metadata_languages.each do |language|
language_path = File.join(play_metadata_path, language)
if upload_metadata
metadata_fields = Supply::AVAILABLE_METADATA_FIELDS.select { |field| File.exist?(File.join(language_path, "#{field}.txt")) }
unless metadata_fields.empty?
# This returns Supply::Listing, whose save writes through the same active edit.
listing = client.listing_for_language(language)
metadata_fields.each do |field|
listing.public_send("#{field}=", File.read(File.join(language_path, "#{field}.txt"), encoding: "UTF-8"))
end
listing.save
end
end
if upload_images
Supply::IMAGES_TYPES.each do |image_type|
path = Dir.glob(File.join(language_path, "images", "#{image_type}.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort.last
client.upload_image(image_path: path, image_type: image_type, language: language) if path
end
end
next unless upload_screenshots
Supply::SCREENSHOT_TYPES.each do |screenshot_type|
paths = Dir.glob(File.join(language_path, "images", screenshot_type, "*.{png,jpg,jpeg}"), File::FNM_CASEFOLD).sort
next if paths.empty?
client.clear_screenshots(image_type: screenshot_type, language: language)
paths.each { |path| client.upload_image(image_path: path, image_type: screenshot_type, language: language) }
end
end
end
def fastlane_boolean_env(name, default:)
value = ENV[name]
return default if value.nil?
normalized = value.downcase
return true if ["1", "yes", "true", "on"].include?(normalized)
return false if ["0", "no", "false", "off"].include?(normalized)
UI.user_error!("#{name} must be true/false, yes/no, on/off, or 1/0.")
end
def upload_play_builds_atomically!(phone_artifact_path:, wear_artifact_path:, version_metadata:, upload_metadata:, upload_images:, upload_screenshots:)
previous_supply_config = Supply.config
client = nil
begin
Supply.config = {
ack_bundle_installation_warning: fastlane_boolean_env("ACK_BUNDLE_INSTALLATION_WARNING", default: false),
changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_CHANGES_NOT_SENT_FOR_REVIEW", default: false),
rescue_changes_not_sent_for_review: fastlane_boolean_env("SUPPLY_RESCUE_CHANGES_NOT_SENT_FOR_REVIEW", default: true)
}
client = Supply::Client.make_from_config(params: play_auth_options.merge(timeout: (ENV["SUPPLY_TIMEOUT"] || "300").to_i))
client.begin_edit(package_name: play_package_name)
phone_version_code = client.upload_bundle(phone_artifact_path)
wear_version_code_value = client.upload_bundle(wear_artifact_path)
expected_phone_version_code = version_metadata.fetch(:version_code)
expected_wear_version_code = wear_version_code(expected_phone_version_code)
UI.user_error!("Uploaded phone AAB versionCode #{phone_version_code}, expected #{expected_phone_version_code}.") unless phone_version_code.to_i == expected_phone_version_code
UI.user_error!("Uploaded Wear AAB versionCode #{wear_version_code_value}, expected #{expected_wear_version_code}.") unless wear_version_code_value.to_i == expected_wear_version_code
update_play_track!(
client,
track_name: play_track,
version_code: phone_version_code,
version_name: version_metadata.fetch(:version)
)
update_play_track!(
client,
track_name: wear_play_track,
version_code: wear_version_code_value,
version_name: version_metadata.fetch(:version)
)
upload_play_listing_assets!(
client,
upload_metadata: upload_metadata,
upload_images: upload_images,
upload_screenshots: upload_screenshots
)
if play_validate_only?
client.validate_current_edit!
UI.success("Successfully validated the atomic phone and Wear upload.")
else
client.commit_current_edit!
UI.success("Successfully committed the atomic phone and Wear upload.")
end
ensure
if client&.current_edit
begin
client.abort_current_edit
rescue => error
UI.important("Could not abort Google Play edit after failure: #{error.message}")
end
end
Supply.config = previous_supply_config
end
end
def play_metadata_path
@@ -190,6 +337,14 @@ def release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-play-release.aab")
end
def wear_release_artifact_path(version)
File.join(android_root, "build", "release-artifacts", "openclaw-#{version}-wear-release.aab")
end
def play_release_artifact_paths(version)
[release_artifact_path(version), wear_release_artifact_path(version)]
end
def build_release_artifacts!
sh(shell_join(["bun", File.join(android_root, "scripts", "build-release-artifacts.ts")]))
end
@@ -291,7 +446,7 @@ end
def validate_android_release_signing!
Dir.chdir(android_root) do
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", "--dry-run"]))
sh(shell_join(["./gradlew", ":app:bundlePlayRelease", ":wear:bundleRelease", "--dry-run"]))
end
end
@@ -299,10 +454,12 @@ def print_android_release_plan!(version_metadata)
UI.message("Android Play release plan:")
UI.message(" package: #{play_package_name}")
UI.message(" track: #{play_track}")
UI.message(" Wear track: #{wear_play_track}")
UI.message(" release_status: #{play_release_status}")
UI.message(" validate_only: #{play_validate_only?}")
UI.message(" versionName: #{version_metadata.fetch(:version)}")
UI.message(" versionCode: #{version_metadata.fetch(:version_code)}")
UI.message(" phone versionCode: #{version_metadata.fetch(:version_code)}")
UI.message(" Wear versionCode: #{wear_version_code(version_metadata.fetch(:version_code))}")
end
def validate_android_release_preflight!(version_metadata)
@@ -344,22 +501,20 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
validate_android_screenshots!
sync_android_changelog!(version_metadata.fetch(:version_code))
artifact_path = release_artifact_path(version_metadata.fetch(:version))
UI.user_error!("Missing Play release artifact at #{artifact_path}. Run pnpm android:release:archive first.") unless File.exist?(artifact_path)
artifact_paths = play_release_artifact_paths(version_metadata.fetch(:version))
missing_artifacts = artifact_paths.reject { |path| File.exist?(path) }
unless missing_artifacts.empty?
UI.user_error!("Missing Play release artifacts at #{missing_artifacts.join(', ')}. Run pnpm android:release:archive first.")
end
upload_to_play_store(
**play_auth_options,
package_name: play_package_name,
aab: artifact_path,
track: play_track,
release_status: play_release_status,
metadata_path: play_metadata_path,
skip_upload_apk: true,
skip_upload_metadata: !upload_metadata,
skip_upload_changelogs: false,
skip_upload_images: !upload_images,
skip_upload_screenshots: !upload_screenshots,
validate_only: play_validate_only?
phone_artifact_path, wear_artifact_path = artifact_paths
upload_play_builds_atomically!(
phone_artifact_path: phone_artifact_path,
wear_artifact_path: wear_artifact_path,
version_metadata: version_metadata,
upload_metadata: upload_metadata,
upload_images: upload_images,
upload_screenshots: upload_screenshots
)
unless play_validate_only?

View File

@@ -104,7 +104,7 @@ Release rules:
- `apkCertificateSha256` in that manifest pins the upload certificate accepted for standalone release APKs; rotate it only with the encrypted keystore.
- `MATCH_PASSWORD` enables Fastlane to pull encrypted Android signing assets into `apps/android/build/release-signing/` before release validation or archive builds.
- Supported pinned Android versions use CalVer: `YYYY.M.D`.
- `versionCode` uses `YYYYMMDDNN`, where `NN` is a two-digit build number for the pinned version.
- Phone `versionCode` uses `YYYYMMDDNN`, where `NN` is `01` through `49`; the matching Wear APK adds `50` and uses `51` through `99`.
- `pnpm android:version:pin -- --from-gateway` promotes the current root gateway version into the pinned Android release version.
- `pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060502` increments another build on the same Android release train.
- `pnpm android:version:sync` updates generated version artifacts.
@@ -113,8 +113,8 @@ Release rules:
- `pnpm android:release:signing:sync:pull` pulls encrypted Android signing assets from `apps-signing`.
- `pnpm android:release:signing:sync:push` creates or refreshes encrypted Android signing assets in `apps-signing`.
- `pnpm android:screenshots` builds and installs the Play debug app, launches deterministic screenshot scenes, and captures raw PNGs.
- `pnpm android:release:archive` builds the signed Play AAB and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` uploads the Play AAB to the configured Google Play track. The default track is `internal`.
- `pnpm android:release:archive` builds the signed phone Play AAB, Wear AAB, and third-party APK into `apps/android/build/release-artifacts/`.
- `pnpm android:release:upload` commits the phone AAB, Wear AAB, metadata, and screenshots in one Google Play edit across the configured phone and `wear:` form-factor tracks. The default tracks are `internal` and `wear:qa`.
- Stable GitHub Release APK publication is separate from Google Play: `OpenClaw Release Publish` dispatches `.github/workflows/android-release.yml`, whose protected `android-release` environment provides `MATCH_PASSWORD`; the repository GitHub App reads the encrypted signing repo.
- Production promotion remains manual in Google Play Console.
- If `pnpm android:release:upload` fails, agent-driven releases must stop and report the failing step. Do not fall back to `pnpm android:release:archive`, `pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts plus Google Play upload commands, or mobile release ref recording.

View File

@@ -13,6 +13,10 @@ androidx-test-ext = "1.3.0"
androidx-test-runner = "1.7.0"
androidx-uiautomator = "2.4.0"
androidx-webkit = "1.16.0"
androidx-wear-compose = "1.6.2"
androidx-wear-input = "1.2.0"
androidx-wear-protolayout = "1.4.1"
androidx-wear-tiles = "1.6.1"
bcprov = "1.85"
commonmark = "0.29.0"
coil = "3.5.0"
@@ -50,6 +54,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
@@ -57,6 +62,12 @@ androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "a
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
androidx-wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "androidx-wear-compose" }
androidx-wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "androidx-wear-compose" }
androidx-wear-input = { module = "androidx.wear:wear-input", version.ref = "androidx-wear-input" }
androidx-wear-protolayout = { module = "androidx.wear.protolayout:protolayout", version.ref = "androidx-wear-protolayout" }
androidx-wear-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material3", version.ref = "androidx-wear-protolayout" }
androidx-wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "androidx-wear-tiles" }
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcode-scanning" }
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }

View File

@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
import { resolveAndroidVersion, syncAndroidVersioning } from "../../../scripts/lib/android-version.ts";
type ReleaseArtifact = {
flavorName: "play" | "third-party";
flavorName: "play" | "wear" | "third-party";
kind: "aab" | "apk";
gradleTask: string;
sourcePath: string;
@@ -165,8 +165,8 @@ function parseArgs(argv: string[]): CliOptions {
switch (arg) {
case "--artifact": {
const value = argv[index + 1];
if (value !== "all" && value !== "play" && value !== "third-party") {
throw new Error("--artifact must be one of: all, play, third-party");
if (value !== "all" && value !== "play" && value !== "wear" && value !== "third-party") {
throw new Error("--artifact must be one of: all, play, wear, third-party");
}
artifact = value;
index += 1;
@@ -189,9 +189,9 @@ function parseArgs(argv: string[]): CliOptions {
case "--help": {
console.log(
[
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--artifact all|play|third-party] [--dry-run] [--verify-apk PATH]",
"Usage: bun apps/android/scripts/build-release-artifacts.ts [--artifact all|play|wear|third-party] [--dry-run] [--verify-apk PATH]",
"",
"Builds the signed Play AAB and third-party APK from apps/android/version.json.",
"Builds the signed phone, Wear, and third-party Android artifacts.",
].join("\n"),
);
process.exit(0);
@@ -222,6 +222,12 @@ function pinnedApkCertificateSha256(): string {
function releaseArtifacts(versionName: string): ReleaseArtifact[] {
return [
{
flavorName: "wear",
kind: "aab",
gradleTask: ":wear:bundleRelease",
sourcePath: join(androidDir, "wear", "build", "outputs", "bundle", "release", "wear-release.aab"),
},
{
flavorName: "play",
kind: "aab",
@@ -265,8 +271,24 @@ function writeSha256File(path: string): string {
return hash;
}
function verifyAabSignature(path: string): void {
function verifyAabSignature(path: string, expectedCertificateSha256: string): void {
execFileSync("jarsigner", ["-verify", path], { stdio: "ignore" });
const output = execFileSync("keytool", ["-printcert", "-jarfile", path], {
encoding: "utf8",
env: { ...process.env, LC_ALL: "C", LANG: "C" },
stdio: ["ignore", "pipe", "inherit"],
});
const fingerprints = Array.from(output.matchAll(/^\s*SHA256:\s*([a-fA-F0-9:]+)\s*$/gmu)).map(
(match) => match[1]?.replaceAll(":", "").toLowerCase(),
);
if (fingerprints.length !== 1 || !/^[a-f0-9]{64}$/u.test(fingerprints[0] ?? "")) {
throw new Error(`Expected exactly one SHA-256 signing certificate for ${path}`);
}
if (fingerprints[0] !== expectedCertificateSha256) {
throw new Error(
`AAB signing certificate mismatch for ${path}: expected ${expectedCertificateSha256}, got ${fingerprints[0]}`,
);
}
}
function resolveApkSignerFromSdk(sdkRoot: string | undefined): string | null {
@@ -354,7 +376,7 @@ function verifyArtifactSignature(
expectedCertificateSha256: string,
): void {
if (artifact.kind === "aab") {
verifyAabSignature(outputPath);
verifyAabSignature(outputPath, expectedCertificateSha256);
} else {
verifyApkSignature(outputPath, expectedCertificateSha256);
}

View File

@@ -17,4 +17,5 @@ dependencyResolutionManagement {
rootProject.name = "OpenClawNodeAndroid"
include(":app")
include(":benchmark")
include(":wear")
include(":wear-shared")

View File

@@ -19,6 +19,7 @@ object WearProtocol {
const val RESPONSE_PATH = "/openclaw/wear/v1/response"
const val EVENT_PATH = "/openclaw/wear/v1/event"
const val PHONE_CAPABILITY = "openclaw_phone_proxy_v1"
const val WATCH_CAPABILITY = "openclaw_wear_companion_v1"
// MessageClient has a 100 KiB ceiling. Keep headroom for transport metadata and
// force transcript pagination instead of depending on an edge-sized message.
@@ -53,6 +54,9 @@ enum class WearEventType {
@SerialName("connection")
Connection,
@SerialName("resync")
Resync,
}
@Serializable
@@ -76,12 +80,15 @@ sealed interface WearMessage {
val ok: Boolean,
val result: JsonElement? = null,
val error: WearRpcError? = null,
val eventStreamId: String? = null,
val eventSequence: Long? = null,
) : WearMessage
@Serializable
@SerialName("event")
data class Event(
override val version: Int = WearProtocol.VERSION,
val streamId: String? = null,
val sequence: Long,
val event: WearEventType,
val payload: JsonElement? = null,
@@ -249,11 +256,15 @@ object WearProtocolCodec {
is WearMessage.Request -> message.requestId.isNotBlank()
is WearMessage.Response ->
message.requestId.isNotBlank() &&
(message.eventStreamId == null || message.eventStreamId.isNotBlank()) &&
(message.eventSequence == null || message.eventSequence >= 0) &&
if (message.ok) {
message.error == null
} else {
message.error != null && message.result == null && message.error.code.isNotBlank()
}
is WearMessage.Event -> message.sequence >= 0
is WearMessage.Event ->
(message.streamId == null || message.streamId.isNotBlank()) &&
message.sequence >= 0
}
}

View File

@@ -27,6 +27,8 @@ class WearProtocolTest {
requestId = "req-1",
ok = true,
result = buildJsonObject { put("count", 2) },
eventStreamId = "phone-process-1",
eventSequence = 7,
),
WearMessage.Response(
requestId = "req-2",
@@ -34,6 +36,7 @@ class WearProtocolTest {
error = WearRpcError(code = "unavailable", message = "Phone offline"),
),
WearMessage.Event(
streamId = "phone-process-1",
sequence = 7,
event = WearEventType.Chat,
payload = buildJsonObject { put("state", "delta") },
@@ -66,6 +69,7 @@ class WearProtocolTest {
mapOf(
WearEventType.Chat to "chat",
WearEventType.Connection to "connection",
WearEventType.Resync to "resync",
)
eventNames.forEach { (event, wireName) ->
val message = WearMessage.Event(sequence = 1, event = event)
@@ -78,6 +82,7 @@ class WearProtocolTest {
assertEquals("/openclaw/wear/v1/response", WearProtocol.RESPONSE_PATH)
assertEquals("/openclaw/wear/v1/event", WearProtocol.EVENT_PATH)
assertEquals("openclaw_phone_proxy_v1", WearProtocol.PHONE_CAPABILITY)
assertEquals("openclaw_wear_companion_v1", WearProtocol.WATCH_CAPABILITY)
}
@Test
@@ -125,6 +130,20 @@ class WearProtocolTest {
"""{"type":"response","version":1,"requestId":"req-1","ok":false}""".encodeToByteArray(),
),
)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope),
WearProtocolCodec.decode(
"""{"type":"event","version":1,"streamId":"","sequence":1,"event":"connection"}"""
.encodeToByteArray(),
),
)
assertEquals(
WearDecodeResult.Failure(WearDecodeFailureReason.InvalidEnvelope),
WearProtocolCodec.decode(
"""{"type":"response","version":1,"requestId":"req-1","ok":true,"eventSequence":-1}"""
.encodeToByteArray(),
),
)
}
@Test

View File

@@ -0,0 +1,11 @@
# Wear OS behavior contract
The watch is a paired-phone companion. It never asks for, receives, or stores Gateway credentials, TLS pins, or device-signing identity.
- Given a reachable paired phone and connected Gateway, opening the watch app lists recent non-global sessions. Selecting one shows the latest bounded text transcript.
- Given multiple reachable phones, RPC responses and events are accepted only from the currently preferred phone. A preferred-phone change reloads canonical state before replaying live events.
- Given an unavailable phone or offline Gateway, the app shows one clear recovery state and a refresh action. It does not fall back to direct Gateway access.
- Given a selected session, text input or Wear speech recognition sends one idempotent, non-delivering chat request through the phone. An ambiguous retry reuses its request identity. An active run can be aborted.
- Given ordered chat events, the transcript shows the phone's bounded canonical stream projection. Given a missing sequence or changed phone-process epoch, the app discards uncertain stream state and reloads canonical history. Events racing that snapshot replay only when they share its epoch and are newer than its response watermark, and stream text reconciles without duplication or a reload loop. A legacy phone without response watermarks lets the next event establish its live baseline.
- Given a final assistant message while the app is not visible and notifications are allowed, the watch shows one local-only notification with direct reply. Phone-process recreation rediscovers the reachable watch before delivery. If the preferred phone changes before a notification reply, recovery opens the app to reload the session instead of retrying the stale phone.
- Given the OpenClaw Tile, tapping anywhere opens the watch app. Tile rendering performs no phone or network work and persists no cache.

View File

@@ -0,0 +1,125 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidPhoneVersionCode = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toInt()
val openClawAndroidBuildNumber = openClawAndroidPhoneVersionCode % 100
check(openClawAndroidBuildNumber in 1..49) {
"Android build number must be 01 through 49; Wear reserves 51 through 99."
}
val openClawAndroidWearVersionCode = openClawAndroidPhoneVersionCode + 50
check(openClawAndroidWearVersionCode <= 2_100_000_000) { "Wear versionCode exceeds the Android platform maximum." }
// Data Layer delivery requires the phone and watch packages to share one certificate.
evaluationDependsOn(":app")
val phoneReleaseSigning =
project(":app")
.extensions
.getByType<com.android.build.api.dsl.ApplicationExtension>()
.signingConfigs
.findByName("release")
android {
namespace = "ai.openclaw.wear"
compileSdk = 37
defaultConfig {
// Data Layer traffic is scoped to matching package names and signatures.
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = openClawAndroidWearVersionCode
versionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
}
buildTypes {
release {
if (phoneReleaseSigning != null) {
signingConfig = phoneReleaseSigning
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
lintConfig = rootProject.file("app/lint.xml")
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 {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
implementation(project(":wear-shared"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.material3)
implementation(libs.androidx.wear.input)
implementation(libs.androidx.wear.tiles)
implementation(libs.androidx.wear.protolayout)
implementation(libs.androidx.wear.protolayout.material)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.play.services.wearable)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}

2
apps/android/wear/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
-keepattributes *Annotation*
-keepclassmembers class ai.openclaw.wear.** { *; }

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.type.watch"
android:required="true" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name=".WearApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_openclaw_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_openclaw_launcher"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:taskAffinity=".main">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".WearProxyListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CAPABILITY_CHANGED" />
<data
android:scheme="wear"
android:host="*"
android:path="/openclaw_phone_proxy_v1" />
</intent-filter>
</service>
<service
android:name=".OpenClawTileService"
android:description="@string/tile_description"
android:exported="true"
android:icon="@drawable/ic_openclaw_launcher"
android:label="@string/tile_label"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data
android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/tile_preview" />
</service>
<receiver
android:name=".WearReplyReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,508 @@
package ai.openclaw.wear
import android.Manifest
import android.app.Activity
import android.app.RemoteInput
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.ButtonDefaults
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.input.RemoteInputIntentHelper
class MainActivity : ComponentActivity() {
private val viewModel: WearViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.state.collectAsState()
OpenClawWearApp(
state = state,
onRefresh = viewModel::refresh,
onOpenSession = viewModel::openSession,
onBack = viewModel::closeSession,
onReply = viewModel::sendReply,
onAbort = viewModel::abort,
)
}
}
override fun onStart() {
super.onStart()
(application as WearApplication).onActivityStarted()
}
override fun onStop() {
(application as WearApplication).onActivityStopped()
super.onStop()
}
}
@Composable
internal fun OpenClawWearApp(
state: WearUiState,
onRefresh: () -> Unit,
onOpenSession: (WearSession) -> Unit,
onBack: () -> Unit,
onReply: (String) -> Unit,
onAbort: () -> Unit,
) {
BackHandler(enabled = state.selectedSession != null, onBack = onBack)
MaterialTheme {
AppScaffold {
if (state.selectedSession == null) {
SessionListScreen(state, onRefresh, onOpenSession)
} else {
TranscriptScreen(state, onBack, onRefresh, onReply, onAbort)
}
}
}
}
@Composable
private fun SessionListScreen(
state: WearUiState,
onRefresh: () -> Unit,
onOpenSession: (WearSession) -> Unit,
) {
val context = LocalContext.current
var notificationsGranted by remember {
mutableStateOf(
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED,
)
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
notificationsGranted = granted
}
val listState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = listState) { contentPadding ->
TransformingLazyColumn(
modifier = Modifier.background(OpenClawBackground),
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item { WearHeader(section = "SESSIONS") }
item { ConnectionPanel(state = state, onRefresh = onRefresh) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !notificationsGranted) {
item {
ActionButton(
label = "ENABLE ALERTS",
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
accent = OpenClawCyan,
)
}
}
for (session in state.sessions) {
item {
SessionButton(session = session, onClick = { onOpenSession(session) })
}
}
if (!state.loading && state.connected && state.sessions.isEmpty()) {
item { EmptyLabel("NO RECENT SESSIONS") }
}
state.error?.let { error -> item { ErrorLabel(error) } }
}
}
}
@Composable
private fun TranscriptScreen(
state: WearUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
onReply: (String) -> Unit,
onAbort: () -> Unit,
) {
val session = state.selectedSession ?: return
val context = LocalContext.current
val voiceIntent = remember { createVoiceInputIntent() }
var voiceAvailable by remember(context) {
mutableStateOf(voiceIntent.resolveActivity(context.packageManager) != null)
}
val textInputLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val data = result.data ?: return@rememberLauncherForActivityResult
val text = RemoteInput.getResultsFromIntent(data)?.getCharSequence(REPLY_RESULT_KEY)?.toString()
text?.takeIf { it.isNotBlank() }?.let(onReply)
}
val voiceInputLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val data = result.data ?: return@rememberLauncherForActivityResult
val text = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
text?.takeIf { it.isNotBlank() }?.let(onReply)
}
val listState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = listState) { contentPadding ->
TransformingLazyColumn(
modifier = Modifier.background(OpenClawBackground),
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item { WearHeader(section = session.title.uppercase()) }
item {
Text(
text = if (state.connected) "PHONE READY" else state.status.uppercase(),
color = if (state.connected) OpenClawGreen else OpenClawWarning,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
CompactAction(label = "BACK", onClick = onBack, modifier = Modifier.weight(1f))
CompactAction(label = "SYNC", onClick = onRefresh, modifier = Modifier.weight(1f))
}
}
for (message in state.messages) {
item { MessagePanel(message) }
}
state.streamText?.takeIf { it.isNotBlank() }?.let { text ->
item { StreamingPanel(text) }
}
if (!state.loading && state.messages.isEmpty() && state.streamText.isNullOrBlank()) {
item { EmptyLabel("NO MESSAGES YET") }
}
item {
ActionButton(
label = if (state.sending) "SENDING…" else "REPLY",
onClick = {
val remoteInput = RemoteInput.Builder(REPLY_RESULT_KEY).setLabel("Reply").build()
val intent = RemoteInputIntentHelper.createActionRemoteInputIntent()
RemoteInputIntentHelper.putRemoteInputsExtra(intent, listOf(remoteInput))
textInputLauncher.launch(intent)
},
enabled = state.connected && !state.sending,
accent = OpenClawRed,
)
}
item {
CompactAction(
label = if (voiceAvailable) "VOICE REPLY" else "VOICE UNAVAILABLE",
onClick = {
try {
voiceInputLauncher.launch(voiceIntent)
} catch (_: ActivityNotFoundException) {
voiceAvailable = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = state.connected && !state.sending && voiceAvailable,
)
}
if (state.activeRunId != null || !state.streamText.isNullOrBlank()) {
item {
ActionButton(label = "ABORT RUN", onClick = onAbort, accent = OpenClawWarning)
}
}
state.error?.let { error -> item { ErrorLabel(error) } }
}
}
}
private fun createVoiceInputIntent(): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Reply to OpenClaw")
}
@Composable
private fun WearHeader(section: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "OPENCLAW",
color = OpenClawRed,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
)
Text(
text = section,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ConnectionPanel(
state: WearUiState,
onRefresh: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.background(OpenClawPanel, RoundedCornerShape(20.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(8.dp)
.background(if (state.connected) OpenClawGreen else OpenClawRed, CircleShape),
)
Spacer(Modifier.size(7.dp))
Text(
text =
when {
state.loading -> "CHECKING PHONE"
state.connected -> "PHONE READY"
else -> "PHONE UNAVAILABLE"
},
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(5.dp))
Text(
text = state.status,
color = OpenClawMuted,
fontSize = 11.sp,
lineHeight = 14.sp,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!state.loading) {
Spacer(Modifier.height(8.dp))
CompactAction(label = "REFRESH", onClick = onRefresh, modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
private fun SessionButton(
session: WearSession,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = OpenClawPanel, contentColor = Color.White),
label = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = session.title,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (session.hasActiveRun) "ACTIVE RUN" else session.key.takeLast(32),
color = if (session.hasActiveRun) OpenClawCyan else OpenClawMuted,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
@Composable
private fun MessagePanel(message: WearChatMessage) {
val assistant = message.role == "assistant"
Column(
modifier =
Modifier
.fillMaxWidth()
.background(if (assistant) OpenClawPanel else OpenClawAccentPanel, RoundedCornerShape(18.dp))
.padding(horizontal = 13.dp, vertical = 10.dp),
) {
Text(
text = if (assistant) "OPENCLAW" else "YOU",
color = if (assistant) OpenClawCyan else OpenClawRed,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
Spacer(Modifier.height(3.dp))
Text(
text = message.text,
color = Color.White,
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 8,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun StreamingPanel(text: String) {
Column(
modifier =
Modifier
.fillMaxWidth()
.background(OpenClawPanel, RoundedCornerShape(18.dp))
.padding(horizontal = 13.dp, vertical = 10.dp),
) {
Text(
text = "LIVE",
color = OpenClawGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
Text(
text = text,
color = Color.White,
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 8,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ActionButton(
label: String,
onClick: () -> Unit,
accent: Color,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = accent, contentColor = OpenClawBackground),
label = {
Text(
text = label,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
)
},
)
}
@Composable
private fun CompactAction(
label: String,
onClick: () -> Unit,
modifier: Modifier,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors(containerColor = OpenClawPanel, contentColor = Color.White),
label = {
Text(
text = label,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
)
},
)
}
@Composable
private fun EmptyLabel(text: String) {
Text(
text = text,
color = OpenClawMuted,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(10.dp),
)
}
@Composable
private fun ErrorLabel(text: String) {
Text(
text = text,
color = OpenClawWarning,
fontSize = 11.sp,
lineHeight = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp),
)
}
internal const val REPLY_RESULT_KEY = "openclaw_wear_reply"
private val OpenClawBackground = Color(0xFF07080A)
private val OpenClawPanel = Color(0xFF17191F)
private val OpenClawAccentPanel = Color(0xFF21181C)
private val OpenClawRed = Color(0xFFFF5A67)
private val OpenClawCyan = Color(0xFF70DDF2)
private val OpenClawGreen = Color(0xFF68D391)
private val OpenClawWarning = Color(0xFFF0B35A)
private val OpenClawMuted = Color(0xFFB7BAC2)

View File

@@ -0,0 +1,37 @@
package ai.openclaw.wear
import android.content.ComponentName
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.protolayout.material3.MaterialScope
import androidx.wear.protolayout.material3.primaryLayout
import androidx.wear.protolayout.material3.text
import androidx.wear.protolayout.material3.textButton
import androidx.wear.protolayout.modifiers.clickable
import androidx.wear.protolayout.types.layoutString
import androidx.wear.tiles.Material3TileService
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
class OpenClawTileService : Material3TileService() {
override suspend fun MaterialScope.tileResponse(requestParams: RequestBuilders.TileRequest): TileBuilders.Tile {
val openAction = ActionBuilders.launchAction(ComponentName(this@OpenClawTileService, MainActivity::class.java))
val openClickable = clickable(action = openAction, id = "open_openclaw")
val layout =
primaryLayout(
titleSlot = { text(getString(R.string.app_name).uppercase().layoutString) },
mainSlot = { text(getString(R.string.tile_phone_proxy).layoutString) },
bottomSlot = {
textButton(
onClick = openClickable,
labelContent = { text(getString(R.string.tile_open).layoutString) },
)
},
onClick = openClickable,
)
return TileBuilders.Tile
.Builder()
.setTileTimeline(TimelineBuilders.Timeline.fromLayoutElement(layout))
.build()
}
}

View File

@@ -0,0 +1,41 @@
package ai.openclaw.wear
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.atomic.AtomicInteger
internal class VisibleActivityTracker {
private val count = AtomicInteger()
fun onStarted() {
count.incrementAndGet()
}
fun onStopped() {
count.updateAndGet { current -> (current - 1).coerceAtLeast(0) }
}
fun isVisible(): Boolean = count.get() > 0
}
class WearApplication : Application() {
internal val processScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
internal val proxyClient: WearProxyClient by lazy {
WearProxyClient.create(context = this)
}
internal val gatewayRepository: WearGatewayRepository by lazy {
WearGatewayRepository(proxyClient)
}
private val visibleActivities = VisibleActivityTracker()
internal fun onActivityStarted() = visibleActivities.onStarted()
internal fun onActivityStopped() = visibleActivities.onStopped()
internal fun isActivityVisible(): Boolean = visibleActivities.isVisible()
}

View File

@@ -0,0 +1,263 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.UUID
internal data class WearProxyStatus(
val connected: Boolean,
val detail: String,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearSession(
val key: String,
val title: String,
val updatedAt: Long?,
val hasActiveRun: Boolean,
val phoneNodeId: String,
)
internal data class WearSessionList(
val sessions: List<WearSession>,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearChatMessage(
val id: String?,
val role: String,
val text: String,
val timestamp: Long?,
)
internal data class WearTranscript(
val sessionKey: String,
val messages: List<WearChatMessage>,
val activeRunId: String?,
val activeText: String?,
val eventSequence: Long?,
val phoneNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearChatEvent(
val sessionKey: String?,
val runId: String?,
val state: String?,
val deltaText: String?,
val replace: Boolean,
val streamText: String?,
val streamTextComplete: Boolean,
val message: WearChatMessage?,
)
internal data class WearSendAttempt(
val sessionKey: String,
val message: String,
val idempotencyKey: String,
val phoneNodeId: String,
)
internal class WearSendAttemptTracker(
private val newId: () -> String = { UUID.randomUUID().toString() },
) {
private var ambiguousAttempt: WearSendAttempt? = null
fun begin(
sessionKey: String,
message: String,
phoneNodeId: String,
): WearSendAttempt {
ambiguousAttempt
?.takeIf { it.sessionKey == sessionKey && it.message == message && it.phoneNodeId == phoneNodeId }
?.let { return it }
ambiguousAttempt = null
return WearSendAttempt(sessionKey, message, "wear-${newId()}", phoneNodeId)
}
fun markAmbiguous(attempt: WearSendAttempt) {
ambiguousAttempt = attempt
}
fun markSucceeded(attempt: WearSendAttempt) {
if (ambiguousAttempt == attempt) ambiguousAttempt = null
}
}
internal class WearGatewayRepository(
private val requester: WearRpcRequester,
) {
suspend fun status(expectedNodeId: String? = null): WearProxyStatus {
val response = requester.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId)
val result = response.payload.asObject("proxy.status")
return WearProxyStatus(
connected = result.boolean("connected") ?: false,
detail = result.string("status") ?: "Phone gateway unavailable",
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun sessions(expectedNodeId: String? = null): WearSessionList {
val response =
requester
.request(
WearRpcMethod.SessionsList,
buildJsonObject { put("limit", 30) },
expectedNodeId,
)
val result = response.payload.asObject("sessions.list")
return WearSessionList(
sessions =
(result["sessions"] as? JsonArray)
.orEmpty()
.mapNotNull { parseSession(it, response.sourceNodeId) },
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun history(
sessionKey: String,
expectedNodeId: String,
): WearTranscript {
val response =
requester
.request(
WearRpcMethod.ChatHistory,
buildJsonObject {
put("sessionKey", sessionKey)
put("limit", 20)
put("maxChars", 2_000)
},
expectedNodeId,
)
val result = response.payload.asObject("chat.history")
val inFlight = result["inFlightRun"] as? JsonObject
return WearTranscript(
sessionKey = result.string("sessionKey") ?: sessionKey,
messages = (result["messages"] as? JsonArray).orEmpty().mapNotNull(::parseChatMessage),
activeRunId = inFlight?.string("runId"),
activeText = inFlight?.string("text"),
eventStreamId = response.eventStreamId,
eventSequence = response.eventSequence,
phoneNodeId = response.sourceNodeId,
)
}
suspend fun send(
attempt: WearSendAttempt,
requirePreferredPhone: Boolean = false,
) {
requester.request(
WearRpcMethod.ChatSend,
buildJsonObject {
put("sessionKey", attempt.sessionKey)
put("message", attempt.message)
put("idempotencyKey", attempt.idempotencyKey)
},
attempt.phoneNodeId,
requirePreferredNode = requirePreferredPhone,
)
}
suspend fun abort(
sessionKey: String,
runId: String?,
phoneNodeId: String,
) {
requester.request(
WearRpcMethod.ChatAbort,
buildJsonObject {
put("sessionKey", sessionKey)
runId?.let { put("runId", it) }
},
phoneNodeId,
requirePreferredNode = true,
)
}
}
internal fun parseWearChatEvent(payload: JsonElement?): WearChatEvent? {
val source = payload as? JsonObject ?: return null
return WearChatEvent(
sessionKey = source.string("sessionKey"),
runId = source.string("runId"),
state = source.string("state"),
deltaText = source.string("deltaText"),
replace = source.boolean("replace") ?: false,
streamText = source.string("streamText"),
streamTextComplete = source.boolean("streamTextComplete") ?: false,
message = parseChatMessage(source["message"]),
)
}
private fun parseSession(
element: JsonElement,
phoneNodeId: String,
): WearSession? {
val source = element as? JsonObject ?: return null
val key = source.string("key") ?: return null
val title =
source.string("displayName")
?: source.string("label")
?: key.substringAfterLast(':').ifBlank { "Session" }
return WearSession(
key = key,
title = title,
updatedAt = source.long("updatedAt") ?: source.long("lastActivityAt"),
hasActiveRun = source.boolean("hasActiveRun") ?: false,
phoneNodeId = phoneNodeId,
)
}
internal fun parseChatMessage(element: JsonElement?): WearChatMessage? {
val source = element as? JsonObject ?: return null
val role = source.string("role") ?: return null
val text = contentText(source["content"])
if (text.isBlank()) return null
return WearChatMessage(
id = source.string("id"),
role = role,
text = text,
timestamp = source.long("timestamp"),
)
}
private fun contentText(element: JsonElement?): String =
when (element) {
is JsonPrimitive -> element.contentOrNull.orEmpty()
is JsonArray ->
element
.mapNotNull { part ->
when (part) {
is JsonPrimitive -> part.contentOrNull
is JsonObject -> part.string("text")
else -> null
}
}.filter { it.isNotBlank() }
.joinToString("\n")
else -> ""
}
private fun JsonElement.asObject(method: String): JsonObject = this as? JsonObject ?: throw WearProxyException("invalid_response", "$method returned invalid data")
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
private fun JsonObject.boolean(name: String): Boolean? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
private fun JsonObject.long(name: String): Long? = (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull

View File

@@ -0,0 +1,449 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearDecodeResult
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearMessage
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProtocolCodec
import ai.openclaw.wear.shared.WearRpcMethod
import android.content.Context
import com.google.android.gms.tasks.Task
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
internal fun interface WearNodeResolver {
suspend fun reachablePhoneNodeId(): String?
}
internal fun interface WearMessageTransport {
suspend fun send(
nodeId: String,
path: String,
data: ByteArray,
)
}
internal interface WearRpcRequester {
suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean = false,
): WearRpcResult
}
internal data class WearRpcResult(
val payload: JsonElement,
val eventSequence: Long?,
val sourceNodeId: String,
val eventStreamId: String? = null,
)
internal data class WearInboundEvent(
val sourceNodeId: String,
val sequence: Long,
val event: WearEventType,
val payload: JsonElement?,
val streamId: String? = null,
)
internal class WearProxyException(
val code: String,
override val message: String,
) : IllegalStateException(message)
internal class WearProxyClient private constructor(
private val nodeResolver: WearNodeResolver,
private val transport: WearMessageTransport,
) : WearRpcRequester {
private val pending = ConcurrentHashMap<String, PendingWearRequest>()
private val selectedPhoneNodeId = AtomicReference<String?>()
private val preferredPhoneLock = Any()
private var preferredPhoneKnown = false
private var preferredPhoneNodeId: String? = null
private val inboundMutex = Mutex()
private val mutableEvents =
MutableSharedFlow<WearInboundEvent>(
extraBufferCapacity = MAX_BUFFERED_EVENTS,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<WearInboundEvent> = mutableEvents
private val mutablePreferredPhoneChanges =
MutableSharedFlow<String?>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val preferredPhoneChanges: SharedFlow<String?> = mutablePreferredPhoneChanges
override suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult =
try {
withTimeout(REQUEST_TIMEOUT_MS) {
requestBeforeDeadline(method, params, expectedNodeId, requirePreferredNode)
}
} catch (_: TimeoutCancellationException) {
throw WearProxyException("timeout", "Paired phone did not respond")
}
private suspend fun requestBeforeDeadline(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult {
// Stateful RPCs stay on the phone that supplied their session/transcript.
// Rediscovery here could route a shared session key to a different phone.
val requiredPreferredNodeId = if (requirePreferredNode) resolvePreferredPhoneNode() else null
val nodeId = expectedNodeId ?: requiredPreferredNodeId ?: resolvePreferredPhoneNode()
if (requirePreferredNode && expectedNodeId != null && requiredPreferredNodeId != expectedNodeId) {
throw WearProxyException("phone_changed", "Preferred phone changed during request")
}
val requestId = UUID.randomUUID().toString()
val response = CompletableDeferred<WearMessage.Response>()
val pendingRequest = PendingWearRequest(nodeId = nodeId, response = response)
check(pending.putIfAbsent(requestId, pendingRequest) == null)
return try {
try {
transport.send(
nodeId = nodeId,
path = WearProtocol.REQUEST_PATH,
data =
WearProtocolCodec.encode(
WearMessage.Request(requestId = requestId, method = method, params = params),
),
)
} catch (_: CancellationException) {
currentCoroutineContext().ensureActive()
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} catch (_: Throwable) {
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
}
val envelope = response.await()
if (
(expectedNodeId == null || requirePreferredNode || method.requiresPreferredSnapshotSource()) &&
selectedPhoneNodeId.get() != nodeId
) {
throw WearProxyException("phone_changed", "Preferred phone changed during request")
}
if (!envelope.ok) {
val error = envelope.error
throw WearProxyException(error?.code ?: "unavailable", error?.message ?: "Phone proxy request failed")
}
WearRpcResult(
payload = envelope.result ?: buildJsonObject {},
// Phone and watch can update independently. A missing v1 watermark means
// unknown, so the next event establishes the legacy phone's live baseline.
eventStreamId = envelope.eventStreamId,
eventSequence = envelope.eventSequence,
sourceNodeId = nodeId,
)
} finally {
pending.remove(requestId, pendingRequest)
}
}
suspend fun handleMessage(
sourceNodeId: String,
path: String,
data: ByteArray,
): WearInboundEvent? =
inboundMutex.withLock {
val message = (WearProtocolCodec.decode(data) as? WearDecodeResult.Success)?.message ?: return@withLock null
when {
path == WearProtocol.RESPONSE_PATH && message is WearMessage.Response -> {
pending[message.requestId]
?.takeIf { it.nodeId == sourceNodeId }
?.response
?.complete(message)
null
}
path == WearProtocol.EVENT_PATH && message is WearMessage.Event -> {
if (!acceptEventSource(sourceNodeId)) return@withLock null
val inbound =
WearInboundEvent(
sourceNodeId = sourceNodeId,
streamId = message.streamId,
sequence = message.sequence,
event = message.event,
payload = message.payload,
)
mutableEvents.tryEmit(inbound)
inbound
}
else -> null
}
}
private suspend fun resolvePhoneNode(): String =
try {
nodeResolver.reachablePhoneNodeId()
} catch (_: CancellationException) {
// Play Services can cancel its Task while this request remains active.
// Preserve actual caller cancellation; map transport cancellation below.
currentCoroutineContext().ensureActive()
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} catch (_: Throwable) {
throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
} ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
private suspend fun acceptEventSource(sourceNodeId: String): Boolean {
val snapshot = preferredPhoneSnapshot()
if (snapshot.known) {
val preferred = snapshot.nodeId
selectedPhoneNodeId.set(preferred)
return preferred == sourceNodeId
}
if (selectedPhoneNodeId.get() == sourceNodeId) return true
return try {
resolvePreferredPhoneNode() == sourceNodeId
} catch (err: CancellationException) {
throw err
} catch (_: WearProxyException) {
false
}
}
/** Capability callbacks invalidate cached routing before another old-phone event is accepted. */
fun updatePreferredPhoneNodeId(nodeId: String?) {
val changed =
synchronized(preferredPhoneLock) {
val changed = !preferredPhoneKnown || preferredPhoneNodeId != nodeId
preferredPhoneKnown = true
preferredPhoneNodeId = nodeId
changed
}
selectedPhoneNodeId.set(nodeId)
if (changed) mutablePreferredPhoneChanges.tryEmit(nodeId)
}
private suspend fun resolvePreferredPhoneNode(): String {
preferredPhoneSnapshot().takeIf(PreferredPhoneSnapshot::known)?.let { snapshot ->
return snapshot.nodeId ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
}
val resolved = resolvePhoneNode()
val selected =
synchronized(preferredPhoneLock) {
if (!preferredPhoneKnown) {
preferredPhoneKnown = true
preferredPhoneNodeId = resolved
}
preferredPhoneNodeId
} ?: throw WearProxyException("phone_unavailable", "Paired phone is unavailable")
selectedPhoneNodeId.set(selected)
return selected
}
private fun preferredPhoneSnapshot(): PreferredPhoneSnapshot =
synchronized(preferredPhoneLock) {
PreferredPhoneSnapshot(known = preferredPhoneKnown, nodeId = preferredPhoneNodeId)
}
private data class PreferredPhoneSnapshot(
val known: Boolean,
val nodeId: String?,
)
private data class PendingWearRequest(
val nodeId: String,
val response: CompletableDeferred<WearMessage.Response>,
)
companion object {
private const val REQUEST_TIMEOUT_MS = 10_000L
private const val MAX_BUFFERED_EVENTS = 64
fun create(context: Context): WearProxyClient {
val appContext = context.applicationContext
val capabilityClient = Wearable.getCapabilityClient(appContext)
val messageClient = Wearable.getMessageClient(appContext)
return WearProxyClient(
nodeResolver =
WearNodeResolver {
capabilityClient
.getCapability(WearProtocol.PHONE_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
.await()
.nodes
.sortedWith(compareByDescending<com.google.android.gms.wearable.Node> { it.isNearby }.thenBy { it.id })
.firstOrNull()
?.id
},
transport =
WearMessageTransport { nodeId, path, data ->
messageClient.sendMessage(nodeId, path, data).await()
},
)
}
internal fun createForTests(
nodeResolver: WearNodeResolver,
transport: WearMessageTransport,
): WearProxyClient = WearProxyClient(nodeResolver, transport)
}
}
private fun WearRpcMethod.requiresPreferredSnapshotSource(): Boolean = this == WearRpcMethod.ProxyStatus || this == WearRpcMethod.SessionsList || this == WearRpcMethod.ChatHistory
internal enum class WearSequenceDecision {
Accepted,
AwaitingSnapshot,
GapOrReset,
}
internal class WearEventSequenceTracker {
private var streamId: String? = null
private var lastSequence: Long? = null
private var awaitingSnapshot = false
@Synchronized
fun adoptSnapshot(
streamId: String?,
sequence: Long?,
) {
if (sequence == null) {
this.streamId = streamId
lastSequence = null
awaitingSnapshot = false
return
}
val previous = lastSequence
val streamChanged = this.streamId != streamId && (this.streamId != null || streamId != null)
this.streamId = streamId
if (awaitingSnapshot || previous == null || streamChanged || sequence > previous) lastSequence = sequence
awaitingSnapshot = false
}
@Synchronized
fun accept(
streamId: String?,
sequence: Long,
): WearSequenceDecision {
if (awaitingSnapshot) return WearSequenceDecision.AwaitingSnapshot
val previous = lastSequence
if (previous == null) {
this.streamId = streamId
lastSequence = sequence
return WearSequenceDecision.Accepted
}
if (this.streamId != streamId && (this.streamId != null || streamId != null)) {
awaitingSnapshot = true
return WearSequenceDecision.GapOrReset
}
if (sequence == previous + 1) {
lastSequence = sequence
return WearSequenceDecision.Accepted
}
// Stream epochs expose phone restarts even when the new process happens to
// produce the next numeric sequence. Legacy null epochs still use gap detection.
awaitingSnapshot = true
return WearSequenceDecision.GapOrReset
}
@Synchronized
fun requireSnapshot() {
awaitingSnapshot = true
}
}
internal class WearEventSourceTracker {
private var sourceNodeId: String? = null
fun adopt(sourceNodeId: String) {
this.sourceNodeId = sourceNodeId
}
fun reset() {
sourceNodeId = null
}
fun changed(sourceNodeId: String): Boolean {
val previous = this.sourceNodeId
this.sourceNodeId = sourceNodeId
return previous != null && previous != sourceNodeId
}
}
internal class WearEventResyncBuffer(
private val capacity: Int = MAX_BUFFERED_EVENTS,
) {
// The response watermark splits events already captured by a snapshot from
// later events that raced its delivery. A bounded overflow reappears as a gap.
private val events = LinkedHashMap<Pair<String?, Long>, WearInboundEvent>()
private var buffering = false
@Synchronized
fun begin() {
events.clear()
buffering = true
}
@Synchronized
fun start(event: WearInboundEvent) {
begin()
appendLocked(event)
}
@Synchronized
fun append(event: WearInboundEvent) {
if (buffering) appendLocked(event)
}
@Synchronized
fun drainAfterSnapshot(
streamId: String?,
sequence: Long?,
): List<WearInboundEvent> {
if (!buffering) return emptyList()
buffering = false
val pending =
if (sequence == null) {
// A legacy snapshot has no ordering boundary. It already represents
// pre-response state, so replay could duplicate it; the next live event
// establishes the new sequence baseline.
emptyList()
} else {
events.values
.filter { event -> event.streamId == streamId && event.sequence > sequence }
.sortedBy(WearInboundEvent::sequence)
}
events.clear()
return pending
}
private fun appendLocked(event: WearInboundEvent) {
events[event.streamId to event.sequence] = event
while (events.size > capacity) events.remove(events.keys.first())
}
private companion object {
const val MAX_BUFFERED_EVENTS = 64
}
}
private suspend fun <T> Task<T>.await(): T =
suspendCancellableCoroutine { continuation ->
addOnSuccessListener { value -> if (continuation.isActive) continuation.resume(value) }
addOnFailureListener { error -> if (continuation.isActive) continuation.resumeWithException(error) }
addOnCanceledListener { continuation.cancel() }
}

View File

@@ -0,0 +1,38 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearProtocol
import com.google.android.gms.wearable.CapabilityInfo
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import kotlinx.coroutines.runBlocking
class WearProxyListenerService : WearableListenerService() {
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
if (capabilityInfo.name != WearProtocol.PHONE_CAPABILITY) return
val preferredNodeId =
capabilityInfo.nodes
.sortedWith(compareByDescending<com.google.android.gms.wearable.Node> { it.isNearby }.thenBy { it.id })
.firstOrNull()
?.id
(application as? WearApplication)?.proxyClient?.updatePreferredPhoneNodeId(preferredNodeId)
}
override fun onMessageReceived(messageEvent: MessageEvent) {
if (messageEvent.path != WearProtocol.RESPONSE_PATH && messageEvent.path != WearProtocol.EVENT_PATH) return
val app = application as? WearApplication ?: return
// WearableListenerService callbacks use its background looper. Finish the
// bounded Data Layer work before returning so Android retains the service.
runBlocking {
val event =
app.proxyClient.handleMessage(
sourceNodeId = messageEvent.sourceNodeId,
path = messageEvent.path,
data = messageEvent.data,
) ?: return@runBlocking
if (event.event == WearEventType.Chat && !app.isActivityVisible()) {
WearReplyNotifier(applicationContext).show(event)
}
}
}
}

View File

@@ -0,0 +1,286 @@
package ai.openclaw.wear
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.security.MessageDigest
internal class WearReplyNotifier(
private val context: Context,
) {
fun show(inbound: WearInboundEvent) {
val event = parseWearChatEvent(inbound.payload) ?: return
if (event.state != "final") return
val message = event.message ?: return
if (message.role != "assistant") return
val sessionKey = event.sessionKey ?: return
if (!notificationsAllowed()) return
createChannel()
val fallbackIdentity =
event.runId
?: listOf(
"source:${inbound.sourceNodeId}",
"stream:${inbound.streamId ?: "legacy"}",
"sequence:${inbound.sequence}",
).joinToString("\u0000")
val notificationTag = replyNotificationTag(sessionKey, message, fallbackIdentity)
val requestCode = NOTIFICATION_ID
val replyAction = createReplyAction(sessionKey, notificationTag, inbound.sourceNodeId)
val openPendingIntent = createOpenAppIntent(requestCode)
val agent = Person.Builder().setName("OpenClaw").build()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(message.text)
.setContentIntent(openPendingIntent)
.setAutoCancel(true)
.setLocalOnly(true)
.setOnlyAlertOnce(true)
.setStyle(
NotificationCompat
.MessagingStyle(agent)
.addMessage(message.text, message.timestamp ?: System.currentTimeMillis(), agent),
).addAction(replyAction)
.build()
notify(notificationTag, notification)
}
fun showReplyFailure(
sessionKey: String,
notificationTag: String,
phoneNodeId: String,
) {
if (!notificationsAllowed()) return
createChannel()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_reply_failed_title))
.setContentText(context.getString(R.string.notification_reply_failed_text))
.setAutoCancel(true)
.setLocalOnly(true)
.addAction(createReplyAction(sessionKey, notificationTag, phoneNodeId))
.build()
notify(notificationTag, notification)
}
fun showPreferredPhoneChanged(notificationTag: String) {
if (!notificationsAllowed()) return
createChannel()
val notification =
NotificationCompat
.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notification_phone_changed_title))
.setContentText(context.getString(R.string.notification_phone_changed_text))
.setContentIntent(createOpenAppIntent(NOTIFICATION_ID))
.setAutoCancel(true)
.setLocalOnly(true)
.build()
notify(notificationTag, notification)
}
private fun createOpenAppIntent(requestCode: Int): PendingIntent =
PendingIntent.getActivity(
context,
requestCode,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun createReplyAction(
sessionKey: String,
notificationTag: String,
phoneNodeId: String,
): NotificationCompat.Action {
val replyIntent =
Intent(context, WearReplyReceiver::class.java).apply {
action = replyPendingIntentAction(sessionKey, notificationTag)
putExtra(EXTRA_SESSION_KEY, sessionKey)
putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
putExtra(EXTRA_PHONE_NODE_ID, phoneNodeId)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
context,
NOTIFICATION_ID,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ONE_SHOT,
)
val remoteInput =
RemoteInput
.Builder(REPLY_RESULT_KEY)
.setLabel(context.getString(R.string.notification_reply))
.build()
return NotificationCompat.Action
.Builder(
R.drawable.ic_notification,
context.getString(R.string.notification_reply),
replyPendingIntent,
).addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
}
private fun createChannel() {
val manager = context.getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
),
)
}
private fun notificationsAllowed(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun notify(
notificationTag: String,
notification: android.app.Notification,
) {
if (!notificationsAllowed()) return
try {
NotificationManagerCompat.from(context).notify(notificationTag, NOTIFICATION_ID, notification)
} catch (_: SecurityException) {
// Permission can be revoked between the explicit check and notify().
}
}
private companion object {
const val CHANNEL_ID = "openclaw_wear_replies"
}
}
class WearReplyReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
val sessionKey = intent.getStringExtra(EXTRA_SESSION_KEY)?.takeIf { it.isNotBlank() } ?: return
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)?.takeIf { it.isNotBlank() } ?: return
val phoneNodeId = intent.getStringExtra(EXTRA_PHONE_NODE_ID)?.takeIf { it.isNotBlank() } ?: return
val reply =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(REPLY_RESULT_KEY)
?.toString()
?.trim()
?.takeIf { it.isNotEmpty() } ?: return
val pendingResult = goAsync()
val app =
context.applicationContext as? WearApplication
?: run {
pendingResult.finish()
return
}
app.processScope.launch {
try {
// Broadcast receivers have a short execution window. Bound discovery,
// transport, and response wait together so finish() always wins the race.
withTimeout(REPLY_BROADCAST_TIMEOUT_MS) {
app.gatewayRepository.send(
WearSendAttempt(
sessionKey = sessionKey,
message = reply,
idempotencyKey = notificationReplyIdempotencyKey(sessionKey, notificationTag, reply),
phoneNodeId = phoneNodeId,
),
requirePreferredPhone = true,
)
}
NotificationManagerCompat.from(context).cancel(notificationTag, NOTIFICATION_ID)
} catch (err: TimeoutCancellationException) {
Log.w(LOG_TAG, "Wear notification reply timed out", err)
WearReplyNotifier(context.applicationContext).showReplyFailure(sessionKey, notificationTag, phoneNodeId)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.w(LOG_TAG, "Wear notification reply failed", err)
val notifier = WearReplyNotifier(context.applicationContext)
when (notificationReplyFailureAction(err)) {
NotificationReplyFailureAction.RetrySamePhone ->
notifier.showReplyFailure(sessionKey, notificationTag, phoneNodeId)
NotificationReplyFailureAction.OpenApp -> notifier.showPreferredPhoneChanged(notificationTag)
}
} finally {
pendingResult.finish()
}
}
}
}
internal const val EXTRA_SESSION_KEY = "openclaw_wear_session_key"
internal const val EXTRA_NOTIFICATION_TAG = "openclaw_wear_notification_tag"
internal const val EXTRA_PHONE_NODE_ID = "openclaw_wear_phone_node_id"
internal fun replyNotificationTag(
sessionKey: String,
message: WearChatMessage,
fallbackIdentity: String,
): String {
val messageIdentity =
when {
message.id != null -> "id:${message.id}"
message.timestamp != null -> "timestamp:${message.timestamp}\u0000${message.role}\u0000${message.text}"
else -> "fallback:$fallbackIdentity"
}
return "ai.openclaw.wear.NOTIFICATION.${sha256("$sessionKey\u0000$messageIdentity")}"
}
internal fun replyPendingIntentAction(
sessionKey: String,
notificationTag: String,
): String = "ai.openclaw.wear.REPLY.${sha256("$sessionKey\u0000$notificationTag")}"
internal fun notificationReplyIdempotencyKey(
sessionKey: String,
notificationTag: String,
reply: String,
): String = "wear-notification-${sha256("$sessionKey\u0000$notificationTag\u0000$reply")}"
internal enum class NotificationReplyFailureAction {
RetrySamePhone,
OpenApp,
}
internal fun notificationReplyFailureAction(error: Throwable): NotificationReplyFailureAction =
if (error is WearProxyException && error.code == "phone_changed") {
NotificationReplyFailureAction.OpenApp
} else {
NotificationReplyFailureAction.RetrySamePhone
}
private fun sha256(value: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray())
return digest.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
}
private const val LOG_TAG = "OpenClawWear"
private const val NOTIFICATION_ID = 7301
private const val REPLY_BROADCAST_TIMEOUT_MS = 5_000L

View File

@@ -0,0 +1,593 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearEventType
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
internal data class WearUiState(
val loading: Boolean = true,
val connected: Boolean = false,
val status: String = "Checking phone",
val sessions: List<WearSession> = emptyList(),
val selectedSession: WearSession? = null,
val messages: List<WearChatMessage> = emptyList(),
val streamText: String? = null,
val activeRunId: String? = null,
val sending: Boolean = false,
val error: String? = null,
)
internal fun WearUiState.resetForPhoneChange(): WearUiState =
copy(
loading = true,
connected = false,
status = "Checking phone",
sessions = emptyList(),
selectedSession = null,
messages = emptyList(),
streamText = null,
activeRunId = null,
error = null,
)
internal class WearViewModel(
application: Application,
) : AndroidViewModel(application) {
private val app = application as WearApplication
private val repository = app.gatewayRepository
private val mutableState = MutableStateFlow(WearUiState())
private val eventSequenceTracker = WearEventSequenceTracker()
private val eventSourceTracker = WearEventSourceTracker()
private val resyncEventBuffer = WearEventResyncBuffer()
private val historyLoadTracker = WearHistoryLoadTracker()
private val sendAttemptTracker = WearSendAttemptTracker()
private var loadJob: Job? = null
val state: StateFlow<WearUiState> = mutableState.asStateFlow()
init {
viewModelScope.launch {
app.proxyClient.events.collect(::handleEvent)
}
viewModelScope.launch {
app.proxyClient.preferredPhoneChanges.collect(::reloadForPreferredPhone)
}
refresh()
}
fun refresh() {
val selected = mutableState.value.selectedSession
if (selected == null) loadSessions() else loadHistory(selected)
}
fun openSession(session: WearSession) {
mutableState.update {
it.copy(
selectedSession = session,
messages = emptyList(),
streamText = null,
activeRunId = null,
error = null,
)
}
loadHistory(session)
}
fun closeSession() {
mutableState.update {
it.copy(
selectedSession = null,
messages = emptyList(),
streamText = null,
activeRunId = null,
error = null,
)
}
loadSessions()
}
fun sendReply(text: String) {
val session = mutableState.value.selectedSession ?: return
val normalized = text.trim()
if (normalized.isEmpty() || mutableState.value.sending) return
val attempt = sendAttemptTracker.begin(session.key, normalized, session.phoneNodeId)
viewModelScope.launch {
mutableState.update { it.copy(sending = true, error = null) }
try {
repository.send(attempt, requirePreferredPhone = true)
sendAttemptTracker.markSucceeded(attempt)
reloadHistoryIfSelected(session.key)
} catch (err: CancellationException) {
sendAttemptTracker.markAmbiguous(attempt)
throw err
} catch (err: Throwable) {
sendAttemptTracker.markAmbiguous(attempt)
recordFailureForSession(err, session.key)
} finally {
mutableState.update { it.copy(sending = false) }
}
}
}
fun abort() {
val current = mutableState.value
val session = current.selectedSession ?: return
viewModelScope.launch {
try {
repository.abort(session.key, current.activeRunId, session.phoneNodeId)
if (mutableState.value.selectedSession?.key != session.key) return@launch
mutableState.update { it.copy(streamText = null, activeRunId = null, error = null) }
reloadHistoryIfSelected(session.key)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
recordFailureForSession(err, session.key)
}
}
}
private fun loadSessions(expectedNodeId: String? = null) {
cancelLoad()
loadJob =
viewModelScope.launch {
mutableState.update { it.copy(loading = true, error = null) }
try {
val status = repository.status(expectedNodeId)
val sessionList =
if (status.connected) {
repository.sessions(status.phoneNodeId)
} else {
WearSessionList(
sessions = emptyList(),
eventStreamId = status.eventStreamId,
eventSequence = status.eventSequence,
phoneNodeId = status.phoneNodeId,
)
}
val pendingEvents =
finishSequenceSnapshot(
streamId = sessionList.eventStreamId,
sequence = sessionList.eventSequence,
sourceNodeId = sessionList.phoneNodeId,
)
loadJob = null
mutableState.update {
it.copy(
loading = false,
connected = status.connected,
status = status.detail,
sessions = sessionList.sessions,
)
}
pendingEvents.forEach(::handleEvent)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
if (err is WearProxyException && err.code == "phone_changed") {
loadJob = null
reloadForPreferredPhone(nodeId = null)
return@launch
}
mutableState.update {
it.copy(
loading = false,
connected = false,
status = "Phone unavailable",
sessions = emptyList(),
error = err.userMessage(),
)
}
loadJob = null
}
}
}
private fun loadHistory(
session: WearSession,
observedMessage: WearChatMessage? = null,
) {
cancelLoad()
val loadToken = historyLoadTracker.start(session.key)
loadJob =
viewModelScope.launch {
mutableState.update { it.copy(loading = true, error = null) }
try {
val transcript = repository.history(session.key, session.phoneNodeId)
if (
mutableState.value.selectedSession?.key != session.key ||
!historyLoadTracker.isCurrent(loadToken)
) {
return@launch
}
val loadResult = historyLoadTracker.finish(loadToken)
val pendingEvents =
finishSequenceSnapshot(
streamId = transcript.eventStreamId,
sequence = transcript.eventSequence,
sourceNodeId = transcript.phoneNodeId,
)
loadJob = null
mutableState.update {
it.copy(
loading = false,
connected = true,
selectedSession = session.copy(phoneNodeId = transcript.phoneNodeId),
messages =
observedMessage?.let { message ->
mergeObservedMessageIntoSnapshot(transcript.messages, message)
} ?: transcript.messages,
streamText =
loadResult.liveStream?.let { live ->
reconcileWearStreamSnapshot(transcript.activeText, live.text, live.complete)
} ?: transcript.activeText,
activeRunId = loadResult.liveStream?.runId ?: transcript.activeRunId,
)
}
pendingEvents.forEach(::handleEvent)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
val currentLoad = historyLoadTracker.isCurrent(loadToken)
if (currentLoad) {
historyLoadTracker.cancel()
loadJob = null
}
if (currentLoad && err is WearProxyException && err.code == "phone_changed") {
reloadForPreferredPhone(nodeId = null)
return@launch
}
if (currentLoad && mutableState.value.selectedSession?.key == session.key) {
recordFailure(err, loading = false)
}
}
}
}
private fun handleEvent(event: WearInboundEvent) {
if (eventSourceTracker.changed(event.sourceNodeId)) {
beginSequenceResync(event, sourceChanged = true)
return
}
when (eventSequenceTracker.accept(event.streamId, event.sequence)) {
WearSequenceDecision.GapOrReset -> {
beginSequenceResync(event, sourceChanged = false)
return
}
WearSequenceDecision.AwaitingSnapshot -> {
resyncEventBuffer.append(event)
return
}
WearSequenceDecision.Accepted -> Unit
}
when (event.event) {
WearEventType.Connection -> handleConnectionEvent(event.payload as? JsonObject)
WearEventType.Chat -> handleChatEvent(event)
WearEventType.Resync -> refresh()
}
}
private fun beginSequenceResync(
event: WearInboundEvent,
sourceChanged: Boolean,
) {
// A source switch or sequence gap invalidates the old phone's live state.
// Buffer this boundary event until the selected phone supplies a watermark.
eventSequenceTracker.requireSnapshot()
resyncEventBuffer.start(event)
if (sourceChanged) {
// Session keys are phone-local identities. Resolve the new phone's catalog
// before issuing any history, reply, or abort request against that source.
mutableState.update { it.resetForPhoneChange() }
loadSessions(event.sourceNodeId)
return
}
val selected = mutableState.value.selectedSession
if (selected != null) {
mutableState.update { it.copy(streamText = null, activeRunId = null) }
loadHistory(selected)
} else {
mutableState.update { it.copy(streamText = null, activeRunId = null) }
loadSessions(event.sourceNodeId)
}
}
private fun reloadForPreferredPhone(nodeId: String?) {
cancelLoad()
eventSequenceTracker.requireSnapshot()
resyncEventBuffer.begin()
if (nodeId == null) {
eventSourceTracker.reset()
} else {
eventSourceTracker.adopt(nodeId)
}
mutableState.update(WearUiState::resetForPhoneChange)
loadSessions(nodeId)
}
private fun finishSequenceSnapshot(
streamId: String?,
sequence: Long?,
sourceNodeId: String,
): List<WearInboundEvent> {
eventSourceTracker.adopt(sourceNodeId)
val pendingEvents = resyncEventBuffer.drainAfterSnapshot(streamId, sequence)
eventSequenceTracker.adoptSnapshot(streamId, sequence)
return pendingEvents
}
private fun handleConnectionEvent(payload: JsonObject?) {
cancelLoad()
val connected = payload.boolean("connected") ?: false
val status = payload.string("status") ?: if (connected) "Connected" else "Gateway offline"
mutableState.update {
it.copy(
loading = false,
connected = connected,
status = status,
streamText = if (connected) it.streamText else null,
activeRunId = if (connected) it.activeRunId else null,
error = if (connected) null else status,
)
}
if (connected) refresh()
}
private fun handleChatEvent(inbound: WearInboundEvent) {
val event = parseWearChatEvent(inbound.payload) ?: return
val selected = mutableState.value.selectedSession ?: return
if (event.sessionKey != selected.key) return
when (event.state) {
"delta" -> {
mutableState.update { current ->
val projectedText = event.streamText ?: event.message?.text
val projectedComplete = event.streamTextComplete || event.message != null || event.replace
val nextText =
if (projectedText != null) {
reconcileWearStreamSnapshot(current.streamText, projectedText, projectedComplete)
} else {
updateWearStreamText(current = current.streamText, delta = event.deltaText, replace = event.replace)
}
historyLoadTracker.observeDelta(
sessionKey = selected.key,
text = nextText,
complete = projectedComplete,
runId = event.runId,
)
current.copy(
loading = false,
streamText = nextText,
activeRunId = event.runId ?: current.activeRunId,
)
}
}
"final" -> {
cancelLoad()
mutableState.update { current ->
current.copy(
messages = event.message?.let { mergeEventMessage(current.messages, it) } ?: current.messages,
streamText = if (event.message == null) current.streamText else null,
activeRunId = null,
)
}
loadHistory(selected, observedMessage = event.message)
}
"aborted", "error" -> {
cancelLoad()
mutableState.update { it.copy(streamText = null, activeRunId = null) }
loadHistory(selected)
}
else ->
event.message?.let { message ->
cancelLoad()
mutableState.update { it.copy(messages = mergeEventMessage(it.messages, message)) }
loadHistory(selected, observedMessage = message)
}
}
}
private fun cancelLoad() {
historyLoadTracker.cancel()
loadJob?.cancel()
loadJob = null
}
private fun reloadHistoryIfSelected(sessionKey: String) {
val selected = mutableState.value.selectedSession?.takeIf { it.key == sessionKey } ?: return
loadHistory(selected)
}
private fun recordFailure(
error: Throwable,
loading: Boolean = mutableState.value.loading,
) {
val message = error.userMessage()
val disconnected = error.isConnectivityFailure()
mutableState.update {
it.copy(
loading = loading,
connected = if (disconnected) false else it.connected,
status = if (disconnected) message else it.status,
streamText = if (disconnected) null else it.streamText,
activeRunId = if (disconnected) null else it.activeRunId,
error = message,
)
}
}
private fun recordFailureForSession(
error: Throwable,
sessionKey: String,
) {
if (error.isConnectivityFailure() || mutableState.value.selectedSession?.key == sessionKey) {
recordFailure(error)
}
}
}
internal fun mergeEventMessage(
messages: List<WearChatMessage>,
message: WearChatMessage,
): List<WearChatMessage> {
val matchIndex =
messages.indexOfFirst { existing ->
when {
message.id != null -> existing.id == message.id
message.timestamp != null ->
existing.id == null &&
existing.timestamp == message.timestamp &&
existing.role == message.role
else -> false
}
}
val merged =
if (matchIndex >= 0) {
messages.toMutableList().also { it[matchIndex] = message }
} else {
messages + message
}
return merged.takeLast(MAX_TRANSCRIPT_MESSAGES)
}
internal fun mergeObservedMessageIntoSnapshot(
messages: List<WearChatMessage>,
message: WearChatMessage,
): List<WearChatMessage> {
val canonicalTail = messages.lastOrNull()
if (
message.id == null &&
canonicalTail != null &&
canonicalTail.role == message.role &&
canonicalTail.text == message.text &&
(message.timestamp == null || canonicalTail.timestamp == message.timestamp)
) {
// History is authoritative after a final event. A matching tail may have
// gained an ID that the event lacked; appending it would duplicate the reply.
return messages.takeLast(MAX_TRANSCRIPT_MESSAGES)
}
return mergeEventMessage(messages, message)
}
internal fun updateWearStreamText(
current: String?,
delta: String?,
replace: Boolean,
): String? {
val next = if (replace) delta else current.orEmpty() + delta.orEmpty()
if (next.isNullOrEmpty()) return next
val codePointCount = next.codePointCount(0, next.length)
if (codePointCount <= MAX_STREAM_CODE_POINTS) return next
val start = next.offsetByCodePoints(0, codePointCount - MAX_STREAM_CODE_POINTS)
return next.substring(start)
}
internal data class WearLiveStreamSnapshot(
val text: String?,
val complete: Boolean,
val runId: String?,
)
internal fun reconcileWearStreamSnapshot(
snapshot: String?,
live: String?,
liveComplete: Boolean,
): String? {
if (live.isNullOrEmpty()) return snapshot
if (snapshot.isNullOrEmpty()) return live
val merged =
if (liveComplete) {
when {
live.startsWith(snapshot) -> live
snapshot.startsWith(live) -> snapshot
else -> live
}
} else {
if (snapshot.startsWith(live)) {
snapshot
} else {
val maxOverlap = minOf(snapshot.length, live.length)
val overlap =
(maxOverlap downTo 1).firstOrNull { count ->
snapshot.hasCodePointBoundary(snapshot.length - count) &&
live.hasCodePointBoundary(count) &&
snapshot.endsWith(live.take(count))
} ?: 0
snapshot + live.drop(overlap)
}
}
return updateWearStreamText(current = null, delta = merged, replace = true)
}
private fun String.hasCodePointBoundary(index: Int): Boolean = index <= 0 || index >= length || !(this[index - 1].isHighSurrogate() && this[index].isLowSurrogate())
internal data class WearHistoryLoadResult(
val liveStream: WearLiveStreamSnapshot?,
)
internal class WearHistoryLoadTracker {
private var generation = 0L
private var sessionKey: String? = null
private var liveStream: WearLiveStreamSnapshot? = null
fun start(sessionKey: String): Long {
generation += 1
this.sessionKey = sessionKey
liveStream = null
return generation
}
fun cancel() {
generation += 1
sessionKey = null
liveStream = null
}
fun observeDelta(
sessionKey: String,
text: String?,
complete: Boolean,
runId: String?,
) {
if (this.sessionKey == sessionKey) {
liveStream = WearLiveStreamSnapshot(text = text, complete = complete, runId = runId)
}
}
fun isCurrent(token: Long): Boolean = token == generation && sessionKey != null
fun finish(
token: Long,
): WearHistoryLoadResult {
if (!isCurrent(token)) return WearHistoryLoadResult(liveStream = null)
val result = WearHistoryLoadResult(liveStream)
sessionKey = null
liveStream = null
return result
}
}
private fun Throwable.userMessage(): String =
when (this) {
is WearProxyException -> message
else -> "Phone proxy unavailable"
}
private fun Throwable.isConnectivityFailure(): Boolean = this is WearProxyException && code in setOf("phone_unavailable", "unavailable", "timeout")
private fun JsonObject?.string(name: String): String? = (this?.get(name) as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
private fun JsonObject?.boolean(name: String): Boolean? = (this?.get(name) as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
private const val MAX_TRANSCRIPT_MESSAGES = 20
private const val MAX_STREAM_CODE_POINTS = 2_000

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#07080A" />
</shape>
</item>
<item
android:width="88dp"
android:height="88dp"
android:gravity="center"
android:drawable="@drawable/ic_openclaw_launcher" />
</layer-list>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C7.6,2 4,5.2 4,9.5C4,13.8 7.2,17 12,17C16.8,17 20,13.8 20,9.5C20,5.2 16.4,2 12,2ZM8,8.5A1.5,1.5 0,1 1,11 8.5A1.5,1.5 0,1 1,8 8.5ZM13,8.5A1.5,1.5 0,1 1,16 8.5A1.5,1.5 0,1 1,13 8.5ZM8.5,12.5C10.5,14 13.5,14 15.5,12.5C14.8,16 9.2,16 8.5,12.5ZM9,17L7.5,22L12,19L16.5,22L15,17Z" />
</vector>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#07080A"
android:pathData="M54,4A50,50 0,1 0,54 104A50,50 0,1 0,54 4Z" />
<path
android:fillColor="#FF4D5A"
android:pathData="M54,27C38,27 28,38 28,55C28,73 39,84 54,84C69,84 80,73 80,55C80,38 70,27 54,27Z" />
<path
android:fillColor="#FF4D5A"
android:pathData="M31,43C22,31 11,35 10,48C10,56 15,61 23,62C17,56 19,48 27,49Z" />
<path
android:fillColor="#FF4D5A"
android:pathData="M77,43C86,31 97,35 98,48C98,56 93,61 85,62C91,56 89,48 81,49Z" />
<path
android:fillColor="#70DDF2"
android:pathData="M42,48A5,5 0,1 0,52 48A5,5 0,1 0,42 48Z" />
<path
android:fillColor="#70DDF2"
android:pathData="M56,48A5,5 0,1 0,66 48A5,5 0,1 0,56 48Z" />
<path
android:fillColor="#07080A"
android:pathData="M45,67C50,71 58,71 63,67C61,75 47,75 45,67Z" />
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#07080A" />
<corners android:radius="24dp" />
</shape>
</item>
<item
android:width="88dp"
android:height="88dp"
android:gravity="center"
android:drawable="@drawable/ic_openclaw_launcher" />
</layer-list>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">OpenClaw</string>
<string name="notification_channel_name">OpenClaw replies</string>
<string name="notification_reply">Reply</string>
<string name="notification_title">OpenClaw reply</string>
<string name="notification_reply_failed_title">Reply not sent</string>
<string name="notification_reply_failed_text">Phone unavailable. Tap Reply to try again.</string>
<string name="notification_phone_changed_title">Open OpenClaw to reply</string>
<string name="notification_phone_changed_text">Your preferred phone changed. Open the app to reload the session before replying.</string>
<string name="tile_label">OpenClaw</string>
<string name="tile_description">Open sessions and reply through your paired phone</string>
<string name="tile_open">OPEN</string>
<string name="tile_phone_proxy">PHONE PROXY</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities">
<string-array name="android_wear_capabilities">
<item tools:ignore="Typos">openclaw_wear_companion_v1</item>
</string-array>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
</full-backup-content>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>

View File

@@ -0,0 +1,223 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WearGatewayRepositoryTest {
private val json = Json
@Test
fun sessionsAndHistoryParseOnlyProjectedContract() =
runTest {
val requester =
RecordingRequester { method, _ ->
when (method) {
WearRpcMethod.SessionsList ->
json.parseToJsonElement(
"""{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"hasActiveRun":true}]}""",
)
WearRpcMethod.ChatHistory ->
json.parseToJsonElement(
"""{"sessionKey":"agent:main","messages":[{"id":"m1","role":"assistant","content":[{"type":"text","text":"hello 😀"}],"timestamp":9}],"inFlightRun":{"runId":"run-1","text":"working"}}""",
)
else -> error("unexpected $method")
}
}
val repository = WearGatewayRepository(requester)
val sessions = repository.sessions()
val history = repository.history("agent:main", sessions.phoneNodeId)
assertEquals("Main", sessions.sessions.single().title)
assertTrue(sessions.sessions.single().hasActiveRun)
assertEquals(7L, sessions.eventSequence)
assertEquals("phone", sessions.phoneNodeId)
assertEquals("phone", sessions.sessions.single().phoneNodeId)
assertEquals("hello 😀", history.messages.single().text)
assertEquals("run-1", history.activeRunId)
assertEquals("working", history.activeText)
assertEquals(7L, history.eventSequence)
assertEquals(setOf("limit"), requester.calls[0].second.keys)
assertEquals(setOf("sessionKey", "limit", "maxChars"), requester.calls[1].second.keys)
}
@Test
fun chatEventPreservesReplaceAndTextOnlyMessage() {
val event =
parseWearChatEvent(
json.parseToJsonElement(
"""{"sessionKey":"main","runId":"run-1","state":"delta","deltaText":"new","replace":true,"streamText":"done","streamTextComplete":true,"message":{"role":"assistant","content":"done"}}""",
),
)
assertEquals("main", event?.sessionKey)
assertEquals("new", event?.deltaText)
assertTrue(event?.replace == true)
assertEquals("done", event?.streamText)
assertTrue(event?.streamTextComplete == true)
assertEquals("done", event?.message?.text)
}
@Test
fun nonTextOrEmptyMessagesAreDropped() {
val binaryOnly =
parseChatMessage(
json.parseToJsonElement(
"""{"role":"assistant","content":[{"type":"image"}]}""",
),
)
assertNull(binaryOnly)
}
@Test
fun ambiguousSendRetryReusesItsIdempotencyKeyUntilSuccess() =
runTest {
val generatedIds = ArrayDeque(listOf("first", "second"))
val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() })
val first = tracker.begin("session-1", "hello", "phone-1")
tracker.markAmbiguous(first)
val retry = tracker.begin("session-1", "hello", "phone-1")
assertEquals(first, retry)
val requester = RecordingRequester { _, _ -> JsonObject(emptyMap()) }
WearGatewayRepository(requester).send(retry)
assertEquals(
"wear-first",
requester.calls
.single()
.second
.getValue("idempotencyKey")
.jsonPrimitive
.content,
)
tracker.markSucceeded(retry)
assertEquals("wear-second", tracker.begin("session-1", "hello", "phone-1").idempotencyKey)
}
@Test
fun differentMessageExpiresAnAbandonedAmbiguousAttempt() {
val generatedIds = ArrayDeque(listOf("first", "second", "third"))
val tracker = WearSendAttemptTracker(newId = { generatedIds.removeFirst() })
val abandoned = tracker.begin("session-1", "hello", "phone-1")
tracker.markAmbiguous(abandoned)
val different = tracker.begin("session-1", "different", "phone-1")
tracker.markSucceeded(different)
val laterHello = tracker.begin("session-1", "hello", "phone-1")
assertEquals("wear-second", different.idempotencyKey)
assertEquals("wear-third", laterHello.idempotencyKey)
}
@Test
fun observedFinalMessageSurvivesAnOlderSnapshotWithoutDuplication() {
val older = WearChatMessage(id = "m1", role = "assistant", text = "older", timestamp = 1)
val final = WearChatMessage(id = "m2", role = "assistant", text = "done", timestamp = 2)
val merged = mergeEventMessage(listOf(older), final)
val deduplicated = mergeEventMessage(merged, final.copy(text = "done!"))
assertEquals(listOf(older, final.copy(text = "done!")), deduplicated)
}
@Test
fun eventMergeReplacesIdentifiedRowsInPlaceAndPreservesUnknownDuplicates() {
val identified = WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1)
val newer = WearChatMessage(id = "m2", role = "user", text = "later", timestamp = 2)
val unknown = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val replaced = mergeEventMessage(listOf(identified, newer), identified.copy(text = "updated"))
val duplicates = mergeEventMessage(listOf(unknown), unknown)
assertEquals(listOf(identified.copy(text = "updated"), newer), replaced)
assertEquals(listOf(unknown, unknown), duplicates)
}
@Test
fun canonicalSnapshotDeduplicatesItsIdentityLessObservedFinal() {
val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7)
val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = null)
assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed))
}
@Test
fun canonicalSnapshotDeduplicatesObservedFinalThatOnlyHasTimestamp() {
val canonical = WearChatMessage(id = "m1", role = "assistant", text = "done", timestamp = 7)
val observed = WearChatMessage(id = null, role = "assistant", text = "done", timestamp = 7)
val other = observed.copy(timestamp = 8)
assertEquals(listOf(canonical), mergeObservedMessageIntoSnapshot(listOf(canonical), observed))
assertEquals(listOf(canonical, other), mergeObservedMessageIntoSnapshot(listOf(canonical), other))
}
@Test
fun historyLoadCarriesRacedCanonicalStreamIntoItsSnapshot() {
val tracker = WearHistoryLoadTracker()
val token = tracker.start("session-1")
tracker.observeDelta("other-session", text = "wrong", complete = true, runId = "other")
tracker.observeDelta("session-1", text = "Hello world", complete = true, runId = "run-1")
assertTrue(tracker.isCurrent(token))
assertEquals(
WearLiveStreamSnapshot(text = "Hello world", complete = true, runId = "run-1"),
tracker.finish(token).liveStream,
)
assertNull(tracker.finish(token).liveStream)
}
@Test
fun stableHistoryLoadCanApplyItsCanonicalSnapshot() {
val tracker = WearHistoryLoadTracker()
val token = tracker.start("session-1")
assertNull(tracker.finish(token).liveStream)
}
@Test
fun racedStreamReconcilesCanonicalPrefixWithoutDuplication() {
assertEquals("Hello world", reconcileWearStreamSnapshot("Hello", "Hello world", liveComplete = true))
assertEquals("Hello world", reconcileWearStreamSnapshot("Hello world", "Hello", liveComplete = true))
assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "He", liveComplete = false))
assertEquals("Hello", reconcileWearStreamSnapshot("Hello", "Hel", liveComplete = false))
assertEquals("Hello world!", reconcileWearStreamSnapshot("Hello world", " world!", liveComplete = false))
}
@Test
fun liveStreamCapPreservesWholeUnicodeCodePoints() {
val oversized = "x".repeat(2_000) + "😀"
val bounded = updateWearStreamText(current = null, delta = oversized, replace = true)
assertEquals(2_000, bounded?.codePointCount(0, bounded.length))
assertTrue(bounded?.endsWith("😀") == true)
}
}
private class RecordingRequester(
private val handler: suspend (WearRpcMethod, JsonObject) -> JsonElement,
) : WearRpcRequester {
val calls = mutableListOf<Pair<WearRpcMethod, JsonObject>>()
override suspend fun request(
method: WearRpcMethod,
params: JsonObject,
expectedNodeId: String?,
requirePreferredNode: Boolean,
): WearRpcResult {
calls += method to params
return WearRpcResult(payload = handler(method, params), eventSequence = 7, sourceNodeId = expectedNodeId ?: "phone")
}
}

View File

@@ -0,0 +1,524 @@
package ai.openclaw.wear
import ai.openclaw.wear.shared.WearDecodeResult
import ai.openclaw.wear.shared.WearEventType
import ai.openclaw.wear.shared.WearMessage
import ai.openclaw.wear.shared.WearProtocol
import ai.openclaw.wear.shared.WearProtocolCodec
import ai.openclaw.wear.shared.WearRpcMethod
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class WearProxyClientTest {
@Test
fun requestUsesReachablePhoneAndCorrelatesResponse() =
runTest {
lateinit var client: WearProxyClient
var sentNode: String? = null
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport =
WearMessageTransport { nodeId, path, data ->
sentNode = nodeId
assertEquals(WearProtocol.REQUEST_PATH, path)
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response =
WearProtocolCodec.encode(
WearMessage.Response(
requestId = request.requestId,
ok = true,
result = buildJsonObject { put("connected", JsonPrimitive(true)) },
eventStreamId = "stream-1",
eventSequence = 12,
),
)
client.handleMessage(
sourceNodeId = "phone-nearby",
path = WearProtocol.RESPONSE_PATH,
data = response,
)
},
)
val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
assertEquals("phone-nearby", sentNode)
assertTrue(
response.payload
.jsonObject
.getValue("connected")
.jsonPrimitive
.content
.toBoolean(),
)
assertEquals(12L, response.eventSequence)
assertEquals("stream-1", response.eventStreamId)
assertEquals("phone-nearby", response.sourceNodeId)
}
@Test
fun responseWithoutWatermarkKeepsLegacyBaselineUnknown() =
runTest {
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport =
WearMessageTransport { _, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response =
WearProtocolCodec.encode(
WearMessage.Response(
requestId = request.requestId,
ok = true,
result = buildJsonObject { put("connected", JsonPrimitive(true)) },
),
)
client.handleMessage(
sourceNodeId = "phone-nearby",
path = WearProtocol.RESPONSE_PATH,
data = response,
)
},
)
val response = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
assertEquals(null, response.eventSequence)
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot(response.eventStreamId, response.eventSequence)
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 37))
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 38))
}
@Test
fun inboundMessagesStayBoundToTheSelectedPhone() =
runTest {
var preferredNode = "phone-1"
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { preferredNode },
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
val response = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true))
client.handleMessage("wrong-phone", WearProtocol.RESPONSE_PATH, response)
client.handleMessage(nodeId, WearProtocol.RESPONSE_PATH, response)
},
)
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection))
assertEquals(null, client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event))
assertEquals("phone-1", client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
preferredNode = "phone-2"
client.updatePreferredPhoneNodeId("phone-2")
assertEquals(null, client.handleMessage("phone-1", WearProtocol.EVENT_PATH, event))
assertEquals("phone-2", client.handleMessage("phone-2", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
}
@Test
fun snapshotRequestRejectsOldPhoneAfterPreferredPhoneChanges() =
runTest {
lateinit var client: WearProxyClient
lateinit var request: WearMessage.Request
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-1" },
transport =
WearMessageTransport { _, _, data ->
request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
},
)
client.updatePreferredPhoneNodeId("phone-1")
val pending =
async {
runCatching {
client.request(WearRpcMethod.SessionsList, buildJsonObject {}, expectedNodeId = "phone-1")
}
}
runCurrent()
client.updatePreferredPhoneNodeId("phone-2")
client.handleMessage(
sourceNodeId = "phone-1",
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
assertEquals("phone_changed", (pending.await().exceptionOrNull() as WearProxyException).code)
}
@Test
fun statefulRequestStaysOnItsExpectedPhoneWithoutRediscovery() =
runTest {
lateinit var client: WearProxyClient
var discoveries = 0
var sentNode: String? = null
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"different-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sentNode = nodeId
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val result = client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "state-phone")
assertEquals(0, discoveries)
assertEquals("state-phone", sentNode)
assertEquals("state-phone", result.sourceNodeId)
}
@Test
fun statefulRequestDoesNotReplaceResolverSelectedEventSource() =
runTest {
lateinit var client: WearProxyClient
client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "preferred-phone" },
transport =
WearMessageTransport { nodeId, _, data ->
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
client.request(WearRpcMethod.ChatAbort, buildJsonObject {}, expectedNodeId = "notification-phone")
val event = WearProtocolCodec.encode(WearMessage.Event(sequence = 1, event = WearEventType.Connection))
assertEquals(null, client.handleMessage("notification-phone", WearProtocol.EVENT_PATH, event))
assertEquals("preferred-phone", client.handleMessage("preferred-phone", WearProtocol.EVENT_PATH, event)?.sourceNodeId)
}
@Test
fun capabilitySelectionRoutesSnapshotsAndRejectsOldStatefulActions() =
runTest {
lateinit var client: WearProxyClient
var discoveries = 0
val sentNodes = mutableListOf<String>()
client =
WearProxyClient.createForTests(
nodeResolver =
WearNodeResolver {
discoveries += 1
"resolver-phone"
},
transport =
WearMessageTransport { nodeId, _, data ->
sentNodes += nodeId
val request = (WearProtocolCodec.decode(data) as WearDecodeResult.Success).message as WearMessage.Request
client.handleMessage(
sourceNodeId = nodeId,
path = WearProtocol.RESPONSE_PATH,
data = WearProtocolCodec.encode(WearMessage.Response(requestId = request.requestId, ok = true)),
)
},
)
val changed = async { client.preferredPhoneChanges.first() }
runCurrent()
client.updatePreferredPhoneNodeId("phone-2")
val status = client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
val stale =
runCatching {
client.request(
WearRpcMethod.ChatAbort,
buildJsonObject {},
expectedNodeId = "phone-1",
requirePreferredNode = true,
)
}.exceptionOrNull()
assertEquals("phone-2", changed.await())
assertEquals("phone-2", status.sourceNodeId)
assertEquals("phone_changed", (stale as WearProxyException).code)
assertEquals(0, discoveries)
assertEquals(listOf("phone-2"), sentNodes)
}
@Test
fun preferredActionResolvesCapabilityBeforeSendingToStoredPhone() =
runTest {
var sends = 0
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-current" },
transport = WearMessageTransport { _, _, _ -> sends += 1 },
)
val stale =
runCatching {
client.request(
WearRpcMethod.ChatSend,
buildJsonObject {},
expectedNodeId = "phone-old",
requirePreferredNode = true,
)
}.exceptionOrNull()
assertEquals("phone_changed", (stale as WearProxyException).code)
assertEquals(0, sends)
}
@Test
fun missingPhoneFailsWithoutSending() =
runTest {
var sends = 0
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { null },
transport = WearMessageTransport { _, _, _ -> sends += 1 },
)
var code: String? = null
try {
client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, expectedNodeId = null)
} catch (err: WearProxyException) {
code = err.code
}
assertEquals("phone_unavailable", code)
assertEquals(0, sends)
}
@Test
fun discoveryTaskCancellationUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { throw CancellationException("task canceled") },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
@Test
fun sendTaskCancellationUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { "phone-nearby" },
transport = WearMessageTransport { _, _, _ -> throw CancellationException("task canceled") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
@Test
fun eventGapAndResetRequireCanonicalRefresh() {
val tracker = WearEventSequenceTracker()
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 5))
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 6))
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 9))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 10))
tracker.adoptSnapshot(null, 9)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 8))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(null, 8))
tracker.adoptSnapshot(null, 8)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(null, 1))
tracker.adoptSnapshot(null, 1)
assertEquals(WearSequenceDecision.Accepted, tracker.accept(null, 2))
}
@Test
fun phoneProcessEpochForcesRefreshEvenWhenSequenceLooksContiguous() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("old-process", 5)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("new-process", 6))
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept("new-process", 7))
tracker.adoptSnapshot("new-process", 7)
assertEquals(WearSequenceDecision.Accepted, tracker.accept("new-process", 8))
}
@Test
fun snapshotWatermarkMakesTheFirstMissingEventVisible() {
val tracker = WearEventSequenceTracker()
tracker.adoptSnapshot("stream", 10)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept("stream", 12))
tracker.adoptSnapshot("stream", 12)
assertEquals(WearSequenceDecision.Accepted, tracker.accept("stream", 13))
}
@Test
fun resyncBufferReplaysEventsNewerThanTheSnapshotWatermark() {
val tracker = WearEventSequenceTracker()
val buffer = WearEventResyncBuffer()
val missed =
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null)
val raced =
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null)
tracker.adoptSnapshot(null, 3)
assertEquals(WearSequenceDecision.GapOrReset, tracker.accept(missed.streamId, missed.sequence))
buffer.start(missed)
assertEquals(WearSequenceDecision.AwaitingSnapshot, tracker.accept(raced.streamId, raced.sequence))
buffer.append(raced)
val replay = buffer.drainAfterSnapshot(null, 4)
tracker.adoptSnapshot(null, 4)
assertEquals(listOf(5L, 6L), replay.map(WearInboundEvent::sequence))
assertTrue(replay.all { tracker.accept(it.streamId, it.sequence) == WearSequenceDecision.Accepted })
}
@Test
fun resyncBufferDiscardsEventsAlreadyCoveredByTheSnapshot() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null),
)
assertTrue(buffer.drainAfterSnapshot(null, 6).isEmpty())
}
@Test
fun resyncBufferDoesNotReplayAnOldProcessEpoch() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", streamId = "old", sequence = 6, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", streamId = "new", sequence = 1, event = WearEventType.Chat, payload = null),
)
val replay = buffer.drainAfterSnapshot("new", 0)
assertEquals(listOf(1L), replay.map(WearInboundEvent::sequence))
}
@Test
fun legacySnapshotWithoutWatermarkDoesNotReplayAmbiguousEvents() {
val buffer = WearEventResyncBuffer()
buffer.start(
WearInboundEvent(sourceNodeId = "phone", sequence = 5, event = WearEventType.Chat, payload = null),
)
buffer.append(
WearInboundEvent(sourceNodeId = "phone", sequence = 6, event = WearEventType.Chat, payload = null),
)
assertTrue(buffer.drainAfterSnapshot(null, null).isEmpty())
}
@Test
fun eventSourceTrackerRequiresResyncOnlyWhenThePhoneChanges() {
val tracker = WearEventSourceTracker()
assertTrue(!tracker.changed("phone-1"))
assertTrue(!tracker.changed("phone-1"))
assertTrue(tracker.changed("phone-2"))
tracker.adopt("snapshot-phone")
assertTrue(!tracker.changed("snapshot-phone"))
assertTrue(tracker.changed("other-phone"))
}
@Test
fun phoneChangeClearsPhoneLocalSessionState() {
val selected = WearSession(key = "main", title = "Main", updatedAt = null, hasActiveRun = true, phoneNodeId = "phone-1")
val state =
WearUiState(
loading = false,
connected = true,
status = "Connected",
sessions = listOf(selected),
selectedSession = selected,
messages = listOf(WearChatMessage(id = "m1", role = "assistant", text = "old", timestamp = 1)),
streamText = "typing",
activeRunId = "run-1",
sending = true,
error = "old",
)
val reset = state.resetForPhoneChange()
assertTrue(reset.loading)
assertTrue(!reset.connected)
assertTrue(reset.sessions.isEmpty())
assertEquals(null, reset.selectedSession)
assertTrue(reset.messages.isEmpty())
assertEquals(null, reset.streamText)
assertEquals(null, reset.activeRunId)
assertTrue(reset.sending)
assertEquals(null, reset.error)
}
@Test
fun requestDeadlineIncludesPhoneDiscovery() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { awaitCancellation() },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure =
async {
runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
}
advanceUntilIdle()
assertEquals("timeout", (failure.await() as WearProxyException).code)
}
@Test
fun discoveryFailureUsesConnectivityError() =
runTest {
val client =
WearProxyClient.createForTests(
nodeResolver = WearNodeResolver { error("Play services failed") },
transport = WearMessageTransport { _, _, _ -> error("must not send") },
)
val failure = runCatching { client.request(WearRpcMethod.ProxyStatus, buildJsonObject {}, null) }.exceptionOrNull()
assertEquals("phone_unavailable", (failure as WearProxyException).code)
}
}

View File

@@ -0,0 +1,90 @@
package ai.openclaw.wear
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WearReplyNotifierTest {
@Test
fun visibilityTracksOverlappingActivityLifecycles() {
val tracker = VisibleActivityTracker()
tracker.onStarted()
tracker.onStarted()
tracker.onStopped()
assertTrue(tracker.isVisible())
tracker.onStopped()
assertTrue(!tracker.isVisible())
}
@Test
fun pendingIntentIdentityDoesNotUseCollidingStringHashCodes() {
check("Aa".hashCode() == "BB".hashCode())
val first = replyPendingIntentAction("Aa", "notification-1")
val second = replyPendingIntentAction("BB", "notification-1")
assertNotEquals(first, second)
assertTrue(first.startsWith("ai.openclaw.wear.REPLY."))
}
@Test
fun distinctFinalMessagesUseDistinctNotificationAndReplyIdentities() {
val firstMessage = WearChatMessage(id = "m1", role = "assistant", text = "first", timestamp = 1)
val secondMessage = WearChatMessage(id = "m2", role = "assistant", text = "second", timestamp = 2)
val firstTag = replyNotificationTag("session", firstMessage, "run-1")
val secondTag = replyNotificationTag("session", secondMessage, "run-2")
assertNotEquals(firstTag, secondTag)
assertNotEquals(
replyPendingIntentAction("session", firstTag),
replyPendingIntentAction("session", secondTag),
)
}
@Test
fun missingMessageIdentityUsesStableEventFallback() {
val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val first = replyNotificationTag("session", message, "run-1")
val retry = replyNotificationTag("session", message, "run-1")
val distinct = replyNotificationTag("session", message, "run-2")
assertEquals(first, retry)
assertNotEquals(first, distinct)
}
@Test
fun fallbackIdentitySeparatesPhoneProcessEpochs() {
val message = WearChatMessage(id = null, role = "assistant", text = "same", timestamp = null)
val first = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-1\u0000sequence:1")
val restarted = replyNotificationTag("session", message, "source:phone\u0000stream:epoch-2\u0000sequence:1")
assertNotEquals(first, restarted)
}
@Test
fun notificationRetryIdentityIsStableForTheSameLogicalReply() {
val first = notificationReplyIdempotencyKey("session", "notification", "reply")
val retry = notificationReplyIdempotencyKey("session", "notification", "reply")
val edited = notificationReplyIdempotencyKey("session", "notification", "edited")
assertEquals(first, retry)
assertNotEquals(first, edited)
}
@Test
fun preferredPhoneChangeRequiresAppRecoveryInsteadOfAStaleRetry() {
assertEquals(
NotificationReplyFailureAction.OpenApp,
notificationReplyFailureAction(WearProxyException("phone_changed", "preferred phone changed")),
)
assertEquals(
NotificationReplyFailureAction.RetrySamePhone,
notificationReplyFailureAction(WearProxyException("phone_unavailable", "offline")),
)
}
}

View File

@@ -159,7 +159,7 @@ describe("Android release artifacts", () => {
const result = run(["--artifact", "debug", "--dry-run"]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("--artifact must be one of: all, play, third-party");
expect(result.stderr).toContain("--artifact must be one of: all, play, wear, third-party");
});
it("accepts the pinned standalone APK signing certificate", () => {

View File

@@ -37,6 +37,8 @@ describe("Android Fastlane release upload gates", () => {
it("preflights and records mobile release refs around Play build upload", () => {
const fastfile = readFastfile();
const uploadBuild = functionBody(fastfile, "upload_play_store_build!");
const atomicUpload = functionBody(fastfile, "upload_play_builds_atomically!");
const booleanEnv = functionBody(fastfile, "fastlane_boolean_env");
expect(fastfile).toContain("def mobile_release_ref_command");
expect(fastfile).toContain("def release_git_sha");
@@ -48,12 +50,25 @@ describe("Android Fastlane release upload gates", () => {
expect(uploadBuild).toContain("record_mobile_release_ref!");
expect(uploadBuild.match(/sha: release_sha/g)).toHaveLength(2);
expect(uploadBuild.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
uploadBuild.indexOf("upload_to_play_store("),
uploadBuild.indexOf("upload_play_builds_atomically!("),
);
expect(uploadBuild.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
uploadBuild.indexOf("upload_to_play_store("),
uploadBuild.indexOf("upload_play_builds_atomically!("),
);
expect(uploadBuild).toContain("unless play_validate_only?");
expect(atomicUpload.match(/client\.upload_bundle\(/g)).toHaveLength(2);
expect(atomicUpload.match(/client\.begin_edit\(/g)).toHaveLength(1);
expect(atomicUpload.match(/client\.commit_current_edit!/g)).toHaveLength(1);
expect(atomicUpload).toContain("client.validate_current_edit!");
expect(atomicUpload).toContain("client.abort_current_edit");
expect(booleanEnv).toContain('["1", "yes", "true", "on"]');
expect(booleanEnv).toContain('["0", "no", "false", "off"]');
expect(atomicUpload).toContain(
'fastlane_boolean_env("ACK_BUNDLE_INSTALLATION_WARNING", default: false)',
);
expect(atomicUpload).toContain(
'fastlane_boolean_env("SUPPLY_RESCUE_CHANGES_NOT_SENT_FOR_REVIEW", default: true)',
);
});
it("generates fresh screenshots before building and uploading a release", () => {

View File

@@ -2943,7 +2943,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
).toEqual([
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-test-wear", task: "test-wear" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-wear", task: "build-wear" },
{ check_name: "android-ktlint", task: "ktlint" },
]);
@@ -2965,7 +2967,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
).toEqual([
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-test-wear", task: "test-wear" },
{ check_name: "android-build-play", task: "build-play" },
{ check_name: "android-build-wear", task: "build-wear" },
{ check_name: "android-ktlint", task: "ktlint" },
]);
expect(