mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 03:51:34 +00:00
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:
committed by
GitHub
parent
61bec3a2de
commit
8d5e39afcd
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -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
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -37,6 +37,7 @@ class AndroidLicenseNoticesTest {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"AndroidX Room",
|
||||
"AndroidX Wear",
|
||||
"Bouncy Castle Provider",
|
||||
"Coil",
|
||||
"CommonMark Java",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,4 +17,5 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "OpenClawNodeAndroid"
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
include(":wear")
|
||||
include(":wear-shared")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
11
apps/android/wear/BEHAVIOR.md
Normal file
11
apps/android/wear/BEHAVIOR.md
Normal 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.
|
||||
125
apps/android/wear/build.gradle.kts
Normal file
125
apps/android/wear/build.gradle.kts
Normal 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
2
apps/android/wear/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
-keepattributes *Annotation*
|
||||
-keepclassmembers class ai.openclaw.wear.** { *; }
|
||||
80
apps/android/wear/src/main/AndroidManifest.xml
Normal file
80
apps/android/wear/src/main/AndroidManifest.xml
Normal 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>
|
||||
508
apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt
Normal file
508
apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt
Normal 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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
10
apps/android/wear/src/main/res/drawable/ic_notification.xml
Normal file
10
apps/android/wear/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||
@@ -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>
|
||||
14
apps/android/wear/src/main/res/drawable/tile_preview.xml
Normal file
14
apps/android/wear/src/main/res/drawable/tile_preview.xml
Normal 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>
|
||||
15
apps/android/wear/src/main/res/values/strings.xml
Normal file
15
apps/android/wear/src/main/res/values/strings.xml
Normal 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>
|
||||
7
apps/android/wear/src/main/res/values/wear.xml
Normal file
7
apps/android/wear/src/main/res/values/wear.xml
Normal 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>
|
||||
4
apps/android/wear/src/main/res/xml/backup_rules.xml
Normal file
4
apps/android/wear/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="root" path="." />
|
||||
</full-backup-content>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user