Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2)

Pristine fork source for PROJ-0088. v1 will turn this into a WebView web
shell; the WebSocket node infrastructure stays intact for v2 heartbeat.
This commit is contained in:
Brendan Greenlee 2026-08-01 15:13:34 +00:00
commit 7a380c40ed
656 changed files with 209982 additions and 0 deletions

16
.editorconfig Normal file
View file

@ -0,0 +1,16 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{kt,kts}]
indent_style = space
indent_size = 2
max_line_length = off
ktlint_standard_filename = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_property-naming = disabled

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.gradle/
**/build/
local.properties
.idea/
**/*.iml

26
AGENTS.md Normal file
View file

@ -0,0 +1,26 @@
# Android Release Agent Policy
Root rules still apply. This file adds the Android release guardrails.
## Google Play Releases
- Agent-driven Google Play uploads must use only `pnpm android:release:upload`.
- If `pnpm android:release:upload` exits non-zero, stop immediately and report the failing step.
- After a failed `pnpm android:release:upload`, do not continue with `pnpm android:release:archive`, `pnpm android:release:metadata`, `fastlane android play_store`, `fastlane android metadata`, direct Gradle release artifacts plus Google Play upload commands, Google Play API mutation commands, or mobile release ref recording.
- Do not promote an Android release to production. Production promotion stays manual in Google Play Console unless the user explicitly asks to promote a specific already-prepared release after the failed state has been reported.
- `pnpm android:release:archive` is for local archive validation only. It is not a fallback release path after screenshot, metadata, signing, validation, or upload-lane failure.
## Licenses Screen
- Maintain the Settings-tab Licenses screen when Android app dependencies change.
- Bundled license files live in `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/`.
- License files must be UTF-8 `.txt` files. Do not add Markdown, HTML, RTF, JSON, XML, or generated notice bundles for this screen.
- The Licenses screen discovers bundled `.txt` files at runtime through `AndroidLicenseNotices`; do not hardcode individual license rows in Compose.
- License rows are ordered alphabetically in code by derived display title, case-insensitive, with filename as the tiebreaker. Do not use numeric filename prefixes for ordering.
- The display title is the license filename without the `.txt` extension.
- Filenames should be plain dependency names, for example `Manrope.txt`; the filename is the row title and must not be shown as a row subtitle.
- Do not add OpenClaw, OpenClaw Foundation, or other first-party/self-owned license entries. The screen is for third-party/open-source dependency acknowledgements.
- When adding, removing, or upgrading Android dependencies, audit whether `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/` needs updates. Exclude dependencies owned by OpenClaw Foundation from the published license list.
- Keep license detail bodies rendered as verbatim monospace text.
- Keep the Settings `Licenses` section at the bottom of Settings, after `Account`, with a single `Licenses` row and no row subtitle unless product direction changes.
- When changing license loading or presentation, update `apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt`, then run focused Android validation.

59
CHANGELOG.md Normal file
View file

@ -0,0 +1,59 @@
# OpenClaw Android Changelog
## Unreleased
## 2026.7.4 - 2026-07-30
Adds inline audio/video playback and uploads, session dashboards, run telemetry, chat rewind/fork, a Settings repair assistant, and Wear instant Talk.
Improves the working claw, collapsible details, Skill Workshop flows, and generated images.
Fixes reconnect/session state, Talk transcripts, manual gateway ports, large-text onboarding, reduced motion, and Wear pairing/reply reliability.
Thanks @IWhatsskill, @NianJiuZst, @masatohoshino, @cygnostik, @licheer-zte, and @metaforismo.
## 2026.7.3 - 2026-07-20
Adds a Wear OS companion for sessions, transcripts, text and voice replies, realtime Talk, Gateway controls, notifications, settings, and a launch Tile.
Adds foreground, on-device Voice Wake with editable Gateway-synced wake words, plus copy and save-as-PNG actions for rendered chat widgets.
Fixes composer media leaking across chats and malformed agent or profile initials when display names begin with emoji.
Thanks @sibbl, @IWhatsskill, and @Leon-SK668.
## 2026.7.2 - 2026-07-13
Adds Automations and Skills management with search, filters, editing, run tracking, install safety, and ClawHub risk review.
Improves chat with per-device history, durable approval status, session search, sharing, and agent avatars.
Adds provider model details, build identity, safer permission recovery, fresh Installed Apps consent, and Gateway protocol v3/v4 support.
Thanks @snowzlmbot, @IWhatsskill, @NianJiuZst, and @guarismo.
## 2026.7.1 - 2026-07-08
Adds multi-gateway switching with isolated credentials, history, queues, and notification routing.
Upgrades chat with offline recovery, session search and groups, model and agent pickers, voice notes, actions, link previews, code and math rendering.
Adds workspace files, Cron details, terminal access, and Listen playback.
Improves onboarding, reconnects, keyboards, notification filtering, location, canvas safety, and voice reliability.
Thanks @IWhatsskill, @ioridev, and @narcissus0702.
## 2026.6.11 - 2026-07-01
Improves Android gateway setup with localized onboarding, QR pairing fixes, and support for local mDNS gateway hosts.
Adds clearer recovery guidance for TLS fingerprint timeouts, mobile protocol mismatches, and gateway auth states.
Refreshes native Android localization coverage, including Swedish app naming and localized gateway trust flows.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -0,0 +1,15 @@
{
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"assetPath": "android/openclaw",
"uploadKeystoreEncryptedFile": "upload-keystore.jks.enc",
"gradlePropertiesEncryptedFile": "gradle.properties.enc",
"apkCertificateSha256": "80dbc62315ea216dd6e8a7060735a866ddc464a48ed50fef29ff0550468b9a63",
"materializedRoot": "apps/android/build/release-signing",
"gradlePropertyNames": [
"OPENCLAW_ANDROID_STORE_FILE",
"OPENCLAW_ANDROID_STORE_PASSWORD",
"OPENCLAW_ANDROID_KEY_ALIAS",
"OPENCLAW_ANDROID_KEY_PASSWORD"
]
}

View file

@ -0,0 +1,6 @@
# Shared Android version defaults.
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.7.4
OPENCLAW_ANDROID_VERSION_CODE=2026070401

386
README.md Normal file
View file

@ -0,0 +1,386 @@
## OpenClaw Android App
OpenClaw Android is the officially released Google Play app. It connects to an OpenClaw Gateway as a companion node for chat, voice, approvals, screen, and device-aware automation.
### Current App Surface
- [x] New 4-step onboarding flow
- [x] Connect tab with `Setup Code` + `Manual` modes
- [x] Encrypted persistence for gateway setup/auth state
- [x] Chat UI restyled
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
- [x] QR code scanning in onboarding
- [x] Performance improvements
- [x] Streaming support in chat UI
- [x] Dedicated per-device Android chat session created/adopted on connect without resetting history
- [x] Request camera/location and other permissions in onboarding/settings flow
- [x] Push notifications for gateway/chat status updates
- [x] Security hardening (biometric lock, token handling, safer defaults)
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Foreground on-device Voice Wake with Gateway-synced wake words
- [x] Screen tab full functionality
- [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs
- [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, aborts, and realtime Talk 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 agent and session selection, bounded text-only transcript history, streaming reply state, text and voice replies, abort, realtime Talk within the selected session, paired-phone Gateway controls, local reply notifications, theme and automatic-speech settings, and a launch Tile. Realtime Talk streams watch microphone and playback audio over a temporary Wear OS Data Layer channel; it still uses the phone's authenticated Gateway session and closes when the selected phone or Gateway connection changes. A missing Data Layer event sequence or changed phone-process epoch triggers a fresh history request instead of applying uncertain deltas. Agent and Gateway controls are capability-negotiated so an older paired phone remains usable during staggered updates.
```bash
cd apps/android
./gradlew :wear:testDebugUnitTest :wear:assembleDebug :wear:lintDebug :wear:ktlintCheck
```
## Build / Run
```bash
cd apps/android
./gradlew :app:assemblePlayDebug
./gradlew :app:installPlayDebug
./gradlew :app:testPlayDebugUnitTest
cd ../..
pnpm android:release:archive
```
Third-party debug flavor:
```bash
cd apps/android
./gradlew :app:assembleThirdPartyDebug
./gradlew :app:installThirdPartyDebug
./gradlew :app:testThirdPartyDebugUnitTest
```
Repository-backed debug Gradle invocations, including `pnpm android:run` and
`pnpm android:screenshots`, stamp the full checkout commit and capture one UTC
build timestamp shared by every debug variant in that invocation. Release
tasks still require explicit `openclawBuildCommit` and
`openclawBuildTimestamp` properties so signed artifacts remain reproducible.
Android release archives use the pinned version in `apps/android/version.json`. Update it with:
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
```
Release-owner signing sync:
```bash
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:check
```
The signing sync pulls encrypted Android upload-key assets from the shared `apps-signing` repo and materializes decrypted files under `apps/android/build/release-signing/`.
Standalone release APK verification also requires that key's public certificate SHA-256 fingerprint to match `Config/ReleaseSigning.json`.
Generate phone and Wear OS Google Play screenshots:
```bash
pnpm android:screenshots
```
The screenshot script captures both form factors with retained
`OpenClaw_Screenshots_API36` (Pixel 2) and
`OpenClaw_Wear_Screenshots_API34` (Wear OS Large Round) AVDs. It creates a
missing AVD, boots it headlessly, waits for Android to finish booting, disables
animations, captures the screenshots, then shuts down the emulator it started.
Install the API 36 Google APIs and API 34 Wear OS system images in the local
Android SDK. Use `--form-factor phone|wear` with `--avd` or `--device` to
explicitly capture one form factor from another 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.
Regular final and correction OpenClaw releases publish the signed third-party APK as `OpenClaw-Android.apk` with a checksum manifest and GitHub Actions provenance. `.github/workflows/android-release.yml` is the only automated GitHub Release upload path; `OpenClaw Release Publish` dispatches it while the canonical release is still a draft and blocks publication until the uploaded asset contract verifies.
The protected `android-release` environment supplies `MATCH_PASSWORD`; the repository's read-only GitHub App token checks out encrypted material from `openclaw/apps-signing`. The workflow builds the exact release tag, refuses to replace different existing bytes, and re-downloads the APK for checksum, certificate, and provenance verification.
`pnpm android:release:archive` is for local archive validation only. It is not a
fallback upload path after `pnpm android:release:upload` fails.
Agent-driven Google Play uploads must use `pnpm android:release:upload` as the
only release path. If that command fails, stop and fix the failing screenshot,
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:<track>`), so the default
internal channel publishes to `internal` and `wear:internal`.
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:
```bash
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
```
## Kotlin Lint + Format
```bash
pnpm android:lint
pnpm android:format
```
Android framework/resource lint (separate pass):
```bash
pnpm android:lint:android
```
Direct Gradle tasks:
```bash
cd apps/android
./gradlew :app:ktlintCheck :benchmark:ktlintCheck :wear:ktlintCheck :wear-shared:ktlintCheck
./gradlew :app:ktlintFormat :benchmark:ktlintFormat :wear:ktlintFormat :wear-shared:ktlintFormat
./gradlew :app:lintPlayDebug :app:lintThirdPartyDebug :wear:lintDebug :wear-shared:lintDebug
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
## Macrobenchmark (Startup + Frame Timing)
```bash
cd apps/android
./gradlew :benchmark:connectedDebugAndroidTest
```
Reports are written under:
- `apps/android/benchmark/build/reports/androidTests/connected/`
## Perf CLI (low-noise)
Deterministic startup measurement + hotspot extraction with compact CLI output:
```bash
cd apps/android
./scripts/perf-startup-benchmark.sh
./scripts/perf-startup-hotspots.sh
```
Benchmark script behavior:
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
- Prints median/min/max/COV in one line.
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
Hotspot script behavior:
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
- Writes raw `perf.data` path for deeper follow-up if needed.
## Run on a Real Android Phone (USB)
1) On phone, enable **Developer options** + **USB debugging**.
2) Connect by USB and accept the debugging trust prompt on phone.
3) Verify ADB can see the device:
```bash
adb devices -l
```
4) Install + launch debug build:
```bash
pnpm android:install
pnpm android:run
```
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
### USB-only gateway testing (no LAN dependency)
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
Terminal A (gateway):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
Terminal B (USB tunnel):
```bash
adb reverse tcp:18789 tcp:18789
```
Then in app **Connect → Manual**:
- Host: `127.0.0.1`
- Port: `18789`
- TLS: off
## Hot Reload / Fast Iteration
This app is native Kotlin + Jetpack Compose.
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
## Connect / Pair
1) Start the gateway (on your main machine):
```bash
pnpm openclaw gateway --port 18789 --verbose
```
2) In the Android app:
- Open the **Connect** tab.
- Use **Setup Code** or **Manual** mode to connect.
3) Approve pairing (on the gateway machine):
```bash
openclaw devices list
openclaw devices approve <requestId>
```
More details: `docs/platforms/android.md`.
## Permissions
- Discovery:
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
- Location:
- Both flavors: `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` for foreground checks.
- Third-party flavor only: `ACCESS_BACKGROUND_LOCATION` plus `FOREGROUND_SERVICE_LOCATION` for user-enabled `Always` checks.
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
- Camera:
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Google Play Restricted Permissions
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
- `READ_SMS`
- `SEND_SMS`
- `READ_CALL_LOG`
Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- The Play build removes these behind the `play` flavor.
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, `READ_EXTERNAL_STORAGE`, and background location; hides SMS, Call Log, Photos, and `Always` location surfaces.
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality, and offers explicit `Always` location opt-in through Android settings.
Policy links:
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
Other Play-restricted surfaces to watch if added later:
- `ACCESS_BACKGROUND_LOCATION`
- `MANAGE_EXTERNAL_STORAGE`
- `QUERY_ALL_PACKAGES`
- `REQUEST_INSTALL_PACKAGES`
- `AccessibilityService`
Reference links:
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
Pre-req checklist:
1) Gateway is running and reachable from the Android app.
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
3) App stays unlocked and in foreground for the whole run.
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
6) No interactive system dialogs should be pending before test start.
7) Canvas host is enabled and reachable from the device for remote Canvas checks (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
8) Local operator test client pairing is approved. If first run fails with `pairing required`, preview the latest pending request, approve the printed request ID, then rerun:
9) For A2UI checks, keep the app on **Screen** tab; the node uses its bundled app-owned A2UI page for message application.
```bash
openclaw devices list
openclaw devices approve --latest # preview only; copy the requestId from output
openclaw devices approve <requestId>
```
Run:
```bash
pnpm android:test:integration
```
Optional overrides:
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` and `talk.ptt.*` in this suite because they require
interactive capture. Use `apps/android/scripts/voice-e2e.sh` for microphone
and voice-path proof.
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`).
Common failure quick-fixes:
- `pairing required` before tests start:
- list pending requests (`openclaw devices list`), then approve with the exact ID (`openclaw devices approve <requestId>`) and rerun.
- `A2UI host not reachable` / `A2UI_HOST_UNAVAILABLE`:
- keep the app foregrounded on the **Screen** tab and rerun. A2UI commands use the bundled app-owned A2UI page; the Gateway Canvas host is still needed for remote Canvas checks, but not for A2UI message application.
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
## Contributions
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.

View file

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

View file

@ -0,0 +1,18 @@
AndroidX Media3
Artifacts:
- androidx.media3:media3-datasource-okhttp:1.10.1
- androidx.media3:media3-exoplayer:1.10.1
- androidx.media3:media3-ui:1.10.1
Copyright 2016 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.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,182 @@
AndroidX Room
Artifacts:
- androidx.room:room-common
- androidx.room:room-runtime
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

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

View file

@ -0,0 +1,19 @@
Bouncy Castle Provider
Artifact: org.bouncycastle:bcprov-jdk18on
License: Bouncy Castle Licence
Bouncy Castle License
Copyright (c) 2000 - 2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,19 @@
Coil
Artifacts:
- io.coil-kt.coil3:coil-compose
- io.coil-kt.coil3:coil-svg
License: Apache License 2.0
Copyright 2026 Coil Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,32 @@
CommonMark Java
Artifacts:
- org.commonmark:commonmark
- org.commonmark:commonmark-ext-autolink
- org.commonmark:commonmark-ext-gfm-strikethrough
- org.commonmark:commonmark-ext-gfm-tables
- org.commonmark:commonmark-ext-task-list-items
License: BSD 2-Clause
Copyright (c) 2015, Atlassian Pty Ltd
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,110 @@
KaTeX is licensed under the MIT License. KaTeX bundles fonts under the SIL Open Font License.
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,184 @@
Kotlin libraries
Artifacts:
- org.jetbrains.kotlin:kotlin-stdlib
- org.jetbrains.kotlinx:kotlinx-coroutines-android
- org.jetbrains.kotlinx:kotlinx-coroutines-core
- org.jetbrains.kotlinx:kotlinx-serialization-json
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,184 @@
OkHttp and Okio
Artifacts:
- com.squareup.okhttp3:okhttp
- com.squareup.okhttp3:okhttp-android
- com.squareup.okio:okio
- com.squareup.okio:okio-jvm
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

@ -0,0 +1,25 @@
SLF4J API
Artifact: org.slf4j:slf4j-api
License: MIT License
Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland)
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,34 @@
dnsjava
Artifact: dnsjava:dnsjava
License: BSD 3-Clause
Copyright (c) 1998-2019, Brian Wellington
Copyright (c) 2005 VeriSign. All rights reserved.
Copyright (c) 2019-2023, dnsjava authors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,23 @@
nibor autolink
Artifact: org.nibor.autolink:autolink
License: MIT License
Copyright (c) 2015 Robin Stocker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

115
VERSIONING.md Normal file
View file

@ -0,0 +1,115 @@
# OpenClaw Android Versioning
Android release builds use pinned app metadata instead of auto-bumping `build.gradle.kts`.
## Version model
- `apps/android/version.json` is the source of truth.
- `version` is the Play `versionName` and uses CalVer: `YYYY.M.D`.
- `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.
Examples:
- `version = 2026.6.2`
- `versionCode = 2026060201`
- matching Wear `versionCode = 2026060251`
- another upload on the same release train: `versionCode = 2026060202`
## Commands
```bash
pnpm android:version
pnpm android:version:check
pnpm android:version:sync
pnpm android:version:pin -- --from-gateway
pnpm android:version:pin -- --version 2026.6.5 --version-code 2026060501
pnpm android:release:signing:plan
MATCH_PASSWORD=<signing repo password> pnpm android:release:signing:sync:pull
pnpm android:release:preflight
```
## Release-note resolution order
When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`, the tooling reads the first available changelog section in this order:
1. exact pinned version, for example `## 2026.6.2`
2. `## Unreleased`
Recommended workflow:
- while iterating on a Google Play release train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
## Release Workflow
1. Pin Android to the intended release version.
2. Run `pnpm android:version:sync`.
3. Update `apps/android/CHANGELOG.md`, then run `pnpm android:version:sync` again if needed.
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 phone and Wear OS Google Play
screenshots with the script-managed Pixel 2 and Wear OS Large Round
emulators.
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.
If `pnpm android:release:upload` fails, stop at that failure. Do not continue by
uploading archived artifacts through `pnpm android:release:archive`,
`pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts,
Google Play API mutation commands, or Play Console mutation commands. Fix the
failing release-lane step, then rerun `pnpm android:release:upload`.
The third-party flavor is archived as a signed APK for non-Play distribution. The Play release lane never uploads it. Official GitHub distribution is owned only by `.github/workflows/android-release.yml`, which publishes regular final and correction tags through the protected `android-release` environment as `OpenClaw-Android.apk`.
## Release SHA tracking
Successful Play build uploads create a non-tag Git ref that records the source
commit for the uploaded store build:
```text
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
```
Example:
```text
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
```
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
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 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.
Do not create this ref after a manual fallback upload. The ref is release-lane
evidence, not a repair mechanism for a failed `pnpm android:release:upload` run.
Useful direct commands:
```bash
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
```
## Signing model
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.
Agent-driven releases must not use those lower-level signing and upload surfaces
to bypass a failed `pnpm android:release:upload` attempt. Report the failing
step and wait for maintainer direction instead.

478
app/build.gradle.kts Normal file
View file

@ -0,0 +1,478 @@
import com.android.build.api.variant.impl.VariantOutputImpl
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Properties
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES")
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 openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
val openClawAndroidVersionCode =
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
fun optionalOpenClawBuildProperty(name: String): String? =
providers
.gradleProperty(name)
.orNull
?.trim()
?.takeIf { it.isNotEmpty() }
val fullGitCommitPattern = Regex("^[a-f0-9]{40}$")
val buildTimestampFormatter =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC)
val explicitOpenClawBuildCommit =
optionalOpenClawBuildProperty("openclawBuildCommit")
?.lowercase()
?.also { commit ->
if (!fullGitCommitPattern.matches(commit)) {
error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.")
}
}
val explicitOpenClawBuildTimestamp =
optionalOpenClawBuildProperty("openclawBuildTimestamp")
?.let { timestamp ->
if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) {
error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.")
}
val instant =
runCatching { Instant.parse(timestamp) }
.getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") }
buildTimestampFormatter.format(instant)
}
val repositoryBuildCommit =
if (explicitOpenClawBuildCommit == null) {
runCatching {
providers
.exec {
workingDir(rootProject.projectDir)
commandLine("git", "rev-parse", "HEAD")
}.standardOutput
.asText
.get()
.trim()
.lowercase()
.takeIf(fullGitCommitPattern::matches)
}.getOrNull()
} else {
null
}
val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown"
// Keep every variant generated by one Gradle invocation on the same build instant.
val invocationBuildTimestamp =
providers.provider { buildTimestampFormatter.format(Instant.now()) }.get()
val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val resolvedAndroidStoreFile =
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
}
}
val hasAndroidReleaseSigning =
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
val wantsAndroidReleaseBuild =
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
val missingAndroidBuildMetadata =
explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
}
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android {
namespace = "ai.openclaw.app"
// AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation.
// targetSdk stays separate so runtime behavior changes remain an explicit migration.
compileSdk = 37
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
}
}
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
assets.directories.add(thirdPartyLicensesDir.path)
}
}
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = openClawAndroidVersionCode
versionName = openClawAndroidVersionName
buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"")
buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"")
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone"
}
create("thirdParty") {
dimension = "store"
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone|location"
}
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
bundle {
language {
// The in-app picker can select a locale outside the device language list.
// Without a Play Core download path, every translated resource must stay installed.
enableSplit = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
androidResources {
generateLocaleConfig = true
localeFilters +=
listOf(
"ar",
"de",
"en",
"es",
"fa",
"fr",
"hi",
"in",
"it",
"ja",
"ko",
"nl",
"pl",
"pt-rBR",
"ru",
"sv",
"th",
"tr",
"uk",
"vi",
"zh-rCN",
"zh-rTW",
)
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
}
}
lint {
lintConfig = file("lint.xml")
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}
}
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)
androidTestImplementation(composeBom)
implementation(project(":wear-shared"))
implementation(libs.play.services.wearable)
implementation(libs.androidx.core.ktx)
// AppCompat owns per-app locale persistence and Activity recreation on API 31-32.
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.webkit)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
// Material Components (XML theme + resources)
implementation(libs.material)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.security.crypto)
// Room owns separate disposable gateway cache and durable client-state databases.
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
implementation(libs.media3.datasource.okhttp)
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.ui)
implementation(libs.bcprov)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.gfm.strikethrough)
implementation(libs.commonmark.ext.gfm.tables)
implementation(libs.commonmark.ext.task.list.items)
// CameraX (for node.invoke camera.* parity)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.video)
implementation(libs.barcode.scanning)
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation(libs.dnsjava)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit4)
testRuntimeOnly(libs.junit.vintage.engine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.uiautomator)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
val validateOpenClawReleaseBuildMetadata =
tasks.register("validateOpenClawReleaseBuildMetadata") {
doLast {
if (missingAndroidBuildMetadata) {
error(
"Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " +
"Use the repository Android release helper.",
)
}
}
}
val validateThirdPartyLicenseAssets =
tasks.register("validateThirdPartyLicenseAssets") {
inputs.dir(thirdPartyLicensesDir)
doLast {
if (!thirdPartyLicensesDir.isDirectory) {
error("Missing Android third-party license directory: ${thirdPartyLicensesDir.relativeTo(rootProject.projectDir)}")
}
val invalidFiles =
thirdPartyLicensesDir
.walkTopDown()
.filter { file -> file.isFile && file.extension.lowercase() != "txt" }
.map { file -> file.relativeTo(thirdPartyLicensesDir).path }
.toList()
if (invalidFiles.isNotEmpty()) {
error(
"Android third-party license assets must be .txt files:\n" +
invalidFiles.joinToString(separator = "\n") { path -> "- $path" },
)
}
}
}
tasks.matching { task -> task.name == "preBuild" }.configureEach {
dependsOn(validateThirdPartyLicenseAssets)
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val preBuildTaskName = "pre${variantNameCapitalized}Build"
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
tasks.matching { task -> task.name == preBuildTaskName }.configureEach {
dependsOn(validateOpenClawReleaseBuildMetadata)
}
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
}
}
}

13
app/lint.xml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="AndroidGradlePluginVersion" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="IconLauncherShape" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
<issue id="HighAppVersionCode" severity="ignore" />
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
<issue id="OldTargetApi" severity="ignore" />
</lint>

8
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,8 @@
-dontwarn org.bouncycastle.**
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

View file

@ -0,0 +1,273 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "924cec9afdb455dced2592399a08f5da",
"entities": [
{
"tableName": "outbox_commands",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, `gatedEpoch` INTEGER, `ownerAgentId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thinkingLevel",
"columnName": "thinkingLevel",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAtMs",
"columnName": "createdAtMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "retryCount",
"columnName": "retryCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "gatedEpoch",
"columnName": "gatedEpoch",
"affinity": "INTEGER"
},
{
"fieldPath": "ownerAgentId",
"columnName": "ownerAgentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "outbox_attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, `position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, `durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "commandId",
"columnName": "commandId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER"
},
{
"fieldPath": "byteLength",
"columnName": "byteLength",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_outbox_attachments_commandId",
"unique": false,
"columnNames": [
"commandId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `${TABLE_NAME}` (`commandId`)"
}
]
},
{
"tableName": "outbox_attachment_chunks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentId` TEXT NOT NULL, `chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
"fields": [
{
"fieldPath": "attachmentId",
"columnName": "attachmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "chunkIndex",
"columnName": "chunkIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"attachmentId",
"chunkIndex"
]
}
},
{
"tableName": "composer_send_admissions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAgentId",
"columnName": "ownerAgentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "client_state_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "gateway_removals",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phase",
"columnName": "phase",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '924cec9afdb455dced2592399a08f5da')"
]
}
}

View file

@ -0,0 +1,146 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b40806fd03e11cc1094f31253e9432fb",
"entities": [
{
"tableName": "cached_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAtMs",
"columnName": "updatedAtMs",
"affinity": "INTEGER"
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey"
]
}
},
{
"tableName": "cached_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textPartsJson",
"columnName": "textPartsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestampMs",
"columnName": "timestampMs",
"affinity": "INTEGER"
},
{
"fieldPath": "idempotencyKey",
"columnName": "idempotencyKey",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey",
"rowOrder"
]
}
},
{
"tableName": "cached_gateway_owners",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40806fd03e11cc1094f31253e9432fb')"
]
}
}

View file

@ -0,0 +1,177 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "6faa4088ccfb9dd4dd8bc3e6619de163",
"entities": [
{
"tableName": "cached_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `status` TEXT, `startedAt` INTEGER, `endedAt` INTEGER, `runtimeMs` INTEGER, `outputTokens` INTEGER, `hasRunMetadata` INTEGER NOT NULL, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAtMs",
"columnName": "updatedAtMs",
"affinity": "INTEGER"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT"
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "runtimeMs",
"columnName": "runtimeMs",
"affinity": "INTEGER"
},
{
"fieldPath": "outputTokens",
"columnName": "outputTokens",
"affinity": "INTEGER"
},
{
"fieldPath": "hasRunMetadata",
"columnName": "hasRunMetadata",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey"
]
}
},
{
"tableName": "cached_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textPartsJson",
"columnName": "textPartsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestampMs",
"columnName": "timestampMs",
"affinity": "INTEGER"
},
{
"fieldPath": "idempotencyKey",
"columnName": "idempotencyKey",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey",
"rowOrder"
]
}
},
{
"tableName": "cached_gateway_owners",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6faa4088ccfb9dd4dd8bc3e6619de163')"
]
}
}

View file

@ -0,0 +1,274 @@
package ai.openclaw.app.ui
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.ui.chat.ChatWidgetExportDestination
import ai.openclaw.app.ui.chat.exportChatWidgetImage
import ai.openclaw.app.ui.chat.widgetExportFileName
import android.content.ClipboardManager
import android.content.ContentUris
import android.content.pm.ActivityInfo
import android.graphics.BitmapFactory
import android.os.SystemClock
import android.provider.MediaStore
import android.view.View
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class CanvasHostLifecycleTest {
@get:Rule
val activityRule = ActivityScenarioRule(CanvasLifecycleTestActivity::class.java)
@Before
fun resetMetrics() {
CanvasLifecycleTestMetrics.reset()
}
@Test
fun hiddenHostRetainsOneWebViewWithoutBlockingShellInput() {
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Unmounted, activity.controller.presentationState.value)
assertNull(activity.host)
}
val presentElapsedMs = activityRule.scenario.readActivity { activity -> activity.presentSlowPage() }
assertTrue(
"present waited for the remote page: ${presentElapsedMs}ms",
presentElapsedMs < canvasLifecycleSlowPageDelayMs / 2,
)
assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished())
val firstWebView =
activityRule.scenario.readActivity { activity ->
val host = checkNotNull(activity.host)
assertEquals(1, host.childCount)
assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value)
val webView = checkNotNull(activity.currentWebView())
activity.hideCanvas()
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
webView
}
assertTrue(
"hidden host remained visible",
activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE },
)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
assertTrue(device.click(device.displayWidth / 2, device.displayHeight / 2))
device.waitForIdle()
activityRule.scenario.onActivity { activity ->
assertEquals(1, activity.underlayClickCount)
repeat(3) {
activity.presentFastPage()
activity.hideCanvas()
}
assertEquals(1, activity.host?.childCount)
assertTrue(firstWebView === activity.currentWebView())
}
}
@Test
fun stalePageCompletionCannotReshowCanvasAfterHide() {
activityRule.scenario.onActivity { activity -> activity.presentSlowPage() }
assertTrue(
"Canvas host was not attached",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null },
)
activityRule.scenario.onActivity { activity -> activity.hideCanvas() }
assertTrue(
"hidden host remained visible",
activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE },
)
assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished())
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
assertEquals(View.INVISIBLE, checkNotNull(activity.host).visibility)
assertNotNull(activity.currentWebView())
}
}
@Test
fun rendererTerminationForgetsFailedPageAndNextShowRecreatesIt() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished())
val firstWebView =
activityRule.scenario.readActivity { activity ->
assertNotNull(activity.controller.currentUrl())
checkNotNull(activity.currentWebView())
}
val terminated =
activityRule.scenario.readActivity { activity ->
activity.currentWebView()?.webViewRenderProcess?.terminate() == true
}
assertTrue("WebView renderer did not terminate", terminated)
assertTrue(
"renderer loss did not clear the invalid WebView",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() == null },
)
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
assertNull(activity.controller.currentUrl())
assertEquals(0, activity.host?.childCount)
activity.showCanvas()
}
assertTrue(
"next show did not create a replacement WebView",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null },
)
assertTrue("replacement scaffold never finished", activityRule.scenario.waitForPageFinished())
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value)
assertEquals(1, activity.host?.childCount)
assertNotEquals(firstWebView, activity.currentWebView())
}
}
@Test
fun configurationChangesKeepTheSameHostAndWebView() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished())
val firstHost = activityRule.scenario.readActivity { activity -> checkNotNull(activity.host) }
val firstWebView = activityRule.scenario.readActivity { activity -> checkNotNull(activity.currentWebView()) }
activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).waitForIdle()
activityRule.scenario.onActivity { activity ->
assertTrue(firstHost === activity.host)
assertTrue(firstWebView === activity.currentWebView())
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
@Test
fun renderedWebViewExportsPngToClipboardAndDownloads() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("widget proof page never finished", activityRule.scenario.waitForPageFinished())
val activity = activityRule.scenario.readActivity { it }
val webView = activityRule.scenario.readActivity { checkNotNull(it.currentWebView()) }
val title = "Widget export proof ${UUID.randomUUID()}"
val fileName = widgetExportFileName(title)
val clipboard = activity.getSystemService(ClipboardManager::class.java)
var downloadsUri: android.net.Uri? = null
try {
runBlocking {
exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Clipboard)
}
val clipboardUri = checkNotNull(clipboard.primaryClip?.getItemAt(0)?.uri)
assertEquals("image/png", activity.contentResolver.getType(clipboardUri))
activity.contentResolver.openInputStream(clipboardUri).use { input ->
val bitmap = checkNotNull(BitmapFactory.decodeStream(input))
assertTrue(bitmap.width > 0)
assertTrue(bitmap.height > 0)
bitmap.recycle()
}
runBlocking {
exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Downloads)
}
downloadsUri =
activity.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(fileName),
null,
).use { cursor ->
checkNotNull(cursor)
assertTrue(cursor.moveToFirst())
ContentUris.withAppendedId(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)),
)
}
assertEquals("image/png", activity.contentResolver.getType(downloadsUri))
activity.contentResolver.openInputStream(downloadsUri).use { input ->
val bitmap = checkNotNull(BitmapFactory.decodeStream(input))
assertTrue(bitmap.width > 0)
assertTrue(bitmap.height > 0)
bitmap.recycle()
}
} finally {
downloadsUri?.let { activity.contentResolver.delete(it, null, null) }
clipboard.clearPrimaryClip()
File(activity.cacheDir, "exports")
.walkTopDown()
.firstOrNull { it.isFile && it.name == fileName }
?.parentFile
?.deleteRecursively()
}
}
}
@RunWith(AndroidJUnit4::class)
class CanvasHostReleaseTest {
@Before
fun resetMetrics() {
CanvasLifecycleTestMetrics.reset()
}
@Test
fun activityTeardownReleasesTheHostAndWebView() {
ActivityScenario.launch(CanvasLifecycleTestActivity::class.java).use { scenario ->
scenario.onActivity { activity -> activity.presentFastPage() }
}
assertTrue(
"AndroidView onRelease was not called",
waitUntil { CanvasLifecycleTestMetrics.hostReleaseCount.get() == 1 },
)
assertEquals(1, CanvasLifecycleTestMetrics.webViewDestroyCount.get())
}
}
private inline fun <T> ActivityScenario<CanvasLifecycleTestActivity>.readActivity(crossinline block: (CanvasLifecycleTestActivity) -> T): T {
var result: Result<T>? = null
onActivity { activity -> result = runCatching { block(activity) } }
return checkNotNull(result).getOrThrow()
}
private fun ActivityScenario<CanvasLifecycleTestActivity>.waitForPageFinished(): Boolean = waitUntilActivity { activity -> activity.currentWebView()?.progress == 100 }
private inline fun ActivityScenario<CanvasLifecycleTestActivity>.waitUntilActivity(
timeoutMs: Long = 5_000L,
crossinline predicate: (CanvasLifecycleTestActivity) -> Boolean,
): Boolean =
waitUntil(timeoutMs) {
readActivity(predicate)
}
private fun waitUntil(
timeoutMs: Long = 5_000L,
predicate: () -> Boolean,
): Boolean {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
if (predicate()) return true
SystemClock.sleep(20)
}
return predicate()
}

View file

@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<!-- Compose/Robolectric needs a generic host; the app theme keeps AppCompat dialog tests valid. -->
<activity
android:name="androidx.activity.ComponentActivity"
android:exported="false"
android:theme="@style/Theme.OpenClawNode" />
<activity
android:name=".ui.CanvasLifecycleTestActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<receiver
android:name=".VoiceE2eReceiver"
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>
</receiver>
<service
android:name=".VoiceE2eService"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,242 @@
package ai.openclaw.app
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.File
private const val tag = "VoiceE2E"
private const val resultFileName = "voice_e2e_result.json"
class VoiceE2eReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
context.startService(
Intent(context, VoiceE2eService::class.java)
.putExtras(intent),
)
}
}
class VoiceE2eService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
val command = intent ?: return START_NOT_STICKY
serviceScope.launch {
try {
runCommand(command)
} finally {
stopSelf(startId)
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
private suspend fun runCommand(intent: Intent) {
try {
val app = applicationContext as NodeApp
val runtime = app.ensureRuntime()
val mode =
intent
.getDecodedStringExtra("mode")
?.trim()
.orEmpty()
.ifEmpty { "both" }
if (mode == "stop") {
runtime.cancelMicCapture()
runtime.setTalkModeEnabled(false)
writeResult("""{"ok":true,"mode":"stop"}""")
return
}
val connect = !intent.getBooleanExtra("noConnect", false)
val connectTimeoutMs = intent.getLongExtra("connectTimeoutMs", 20_000L)
if (connect) {
configureGateway(runtime = runtime, intent = intent)
}
if (connect || !runtime.isConnected.value) {
awaitGateway(runtime = runtime, timeoutMs = connectTimeoutMs)
}
startActivity(
Intent(actionOpenVoiceE2e)
.setClass(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
)
if (mode == "connect") {
val resultJson = """{"ok":true,"mode":"connect","connected":true}"""
writeResult(resultJson)
Log.i(tag, "PASS $resultJson")
return
}
val transcript =
intent
.getDecodedStringExtra("transcript")
?.trim()
.orEmpty()
.ifEmpty { "Reply exactly: Android voice e2e normal path ok." }
val realtimeReply =
intent
.getDecodedStringExtra("realtimeAssistant")
?.trim()
.orEmpty()
.ifEmpty { "Android realtime voice e2e relay path ok." }
val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L)
val result =
runtime.runVoiceE2e(
mode = mode,
transcript = transcript,
realtimeAssistantText = realtimeReply,
timeoutMs = timeoutMs,
)
val resultJson = encodeResult(result)
writeResult(resultJson)
Log.i(tag, "PASS $resultJson")
} catch (err: Throwable) {
val resultJson =
buildJsonObject {
put("ok", JsonPrimitive(false))
put("error", JsonPrimitive(err.message ?: err::class.java.simpleName))
}.toString()
writeResult(resultJson)
Log.e(tag, "FAIL $resultJson", err)
}
}
private fun configureGateway(
runtime: NodeRuntime,
intent: Intent,
) {
val host =
intent
.getDecodedStringExtra("host")
?.trim()
.orEmpty()
.ifEmpty { "127.0.0.1" }
val port = intent.getIntExtra("port", 18789)
runtime.setManualEnabled(true)
runtime.setManualHost(host)
runtime.setManualPort(port)
runtime.setManualTls(intent.getBooleanExtra("tls", false))
runtime.setOnboardingCompleted(true)
runtime.connect(
ai.openclaw.app.gateway.GatewayEndpoint
.manual(host, port),
NodeRuntime.GatewayConnectAuth(
token = intent.getDecodedStringExtra("token"),
bootstrapToken = intent.getDecodedStringExtra("bootstrapToken"),
password = intent.getDecodedStringExtra("password"),
),
)
}
private suspend fun awaitGateway(
runtime: NodeRuntime,
timeoutMs: Long,
) {
try {
withTimeout(timeoutMs) {
while (!runtime.isConnected.value) {
voiceE2eTerminalGatewayFailure(runtime.gatewayConnectionProblem.value)?.let { error(it) }
delay(100L)
}
}
} catch (err: TimeoutCancellationException) {
throw IllegalStateException(
voiceE2eGatewayTimeoutMessage(
timeoutMs = timeoutMs,
statusText = runtime.statusText.value,
problem = runtime.gatewayConnectionProblem.value,
),
err,
)
}
}
private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String =
buildJsonObject {
put("ok", JsonPrimitive(true))
put("normal", result.normal?.let(::encodeSlice) ?: JsonNull)
put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull)
}.toString()
private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) =
buildJsonObject {
put("mode", JsonPrimitive(slice.mode))
put("status", JsonPrimitive(slice.status))
put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull)
put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull)
}
private fun writeResult(json: String) {
File(cacheDir, resultFileName).writeText(json)
}
}
private fun Intent.getDecodedStringExtra(name: String): String? {
val encoded = getStringExtra("${name}Base64")
if (!encoded.isNullOrBlank()) {
return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8)
}
return getStringExtra(name)
}
internal fun voiceE2eTerminalGatewayFailure(problem: GatewayConnectionProblem?): String? =
problem
?.takeIf { it.pauseReconnect && !it.canAutoRetry }
?.message
?.trim()
?.takeIf { it.isNotEmpty() }
internal fun voiceE2eGatewayTimeoutMessage(
timeoutMs: Long,
statusText: String,
problem: GatewayConnectionProblem?,
): String {
val detail =
problem
?.message
?.trim()
?.takeIf { it.isNotEmpty() }
?: statusText.trim().takeIf { it.isNotEmpty() }
return buildString {
append("Gateway connection timed out after ")
append(timeoutMs)
append(" ms")
if (detail != null) {
append(": ")
append(detail)
}
}
}

View file

@ -0,0 +1,129 @@
package ai.openclaw.app.ui
import ai.openclaw.app.node.CanvasController
import android.os.Bundle
import android.os.SystemClock
import android.util.Base64
import android.webkit.WebView
import android.widget.Button
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import java.util.concurrent.atomic.AtomicInteger
const val canvasLifecycleSlowPageDelayMs = 2_000L
class CanvasLifecycleTestActivity : ComponentActivity() {
val controller = CanvasController()
internal var host: CanvasHostView? = null
internal set
var underlayClickCount: Int = 0
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CanvasLifecycleTestContent(
activity = this,
onUnderlayClick = { underlayClickCount += 1 },
)
}
}
fun presentSlowPage(): Long = presentHtml(slowPageHtml)
fun presentFastPage(): Long = presentHtml("<html><body>ready</body></html>")
fun hideCanvas() {
controller.hide()
}
fun showCanvas() {
controller.show()
}
fun currentWebView(): WebView? = host?.currentWebView
private fun presentHtml(html: String): Long {
val startedAt = SystemClock.elapsedRealtime()
val encoded = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
controller.navigate("data:text/html;base64,$encoded")
controller.show()
return SystemClock.elapsedRealtime() - startedAt
}
}
@Composable
private fun CanvasLifecycleTestContent(
activity: CanvasLifecycleTestActivity,
onUnderlayClick: () -> Unit,
) {
val state by activity.controller.presentationState.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
Button(context).apply {
setOnClickListener { onUnderlayClick() }
}
},
modifier = Modifier.fillMaxSize(),
)
if (state != CanvasController.PresentationState.Unmounted) {
AndroidView(
factory = { context ->
CanvasHostView(
context = context,
controller = activity.controller,
isTrustedPage = { false },
onA2uiMessage = {},
).also { host ->
activity.host = host
host.updateVisible(state == CanvasController.PresentationState.Visible)
}
},
update = { host ->
host.updateVisible(state == CanvasController.PresentationState.Visible)
},
modifier = Modifier.fillMaxSize(),
onRelease = { host ->
val hadWebView = host.currentWebView != null
host.release()
if (activity.host === host) activity.host = null
CanvasLifecycleTestMetrics.hostReleaseCount.incrementAndGet()
if (hadWebView) CanvasLifecycleTestMetrics.webViewDestroyCount.incrementAndGet()
},
)
}
}
}
object CanvasLifecycleTestMetrics {
val hostReleaseCount = AtomicInteger()
val webViewDestroyCount = AtomicInteger()
fun reset() {
hostReleaseCount.set(0)
webViewDestroyCount.set(0)
}
}
private val slowPageHtml =
"""
<html>
<body>
<script>
const deadline = Date.now() + $canvasLifecycleSlowPageDelayMs;
while (Date.now() < deadline) {}
</script>
ready
</body>
</html>
""".trimIndent()

View file

@ -0,0 +1,160 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application
android:name=".NodeApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.OpenClawNode">
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="${nodeForegroundServiceType}" />
<service
android:name=".wear.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/request" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
<data
android:scheme="wear"
android:host="*"
android:path="/openclaw/wear/v1/realtime/audio" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/realtime/audio/" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- External sender tasks must converge on one visible composer and share queue. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
<data android:mimeType="text/csv" />
<data android:mimeType="text/markdown" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,37 @@
# KaTeX Android assets
- Version: KaTeX 0.17.0 (`v0.17.0`, released 2026-05-22)
- Source: https://github.com/KaTeX/KaTeX/releases/tag/v0.17.0
- Release archive: https://github.com/KaTeX/KaTeX/releases/download/v0.17.0/katex.tar.gz
- Published and verified archive SHA-256: `f77cf2555a40e879a4784e43e229d41867f106125bc05b15161de3e761b64b88`
Only the minified runtime, its stylesheet, and the WOFF2 fonts referenced by that stylesheet are copied from the release archive. `index.html` and `renderer.js` are OpenClaw's local rendering shell. No auto-render extension, mhchem extension, source map, WOFF, or TTF file is bundled.
## SHA-256
```text
0cdd387c9590a1a9f9794560022dbb59654a7d86f187aa0c81495ad42d3a7308 fonts/KaTeX_AMS-Regular.woff2
de7701e42cf1f4cf0b766c03fb27977207eee2f4fd5d76fa82188406da43ea4c fonts/KaTeX_Caligraphic-Bold.woff2
5d53e70ad607c2352162dec9e0923fb54ecdafaccbf604cd8dcf7d00facb989b fonts/KaTeX_Caligraphic-Regular.woff2
74444efd593c005e3f4573b44524704c0af0a937fe911cca9e94068d0d140d3f fonts/KaTeX_Fraktur-Bold.woff2
51814d270d06ff0255dba0799994fa4d8c84d11f09951d47595f4abb1f3602dc fonts/KaTeX_Fraktur-Regular.woff2
0f60d1b897938ec918c8ce073092411baf9438f6739465693ff18b0f9d20b021 fonts/KaTeX_Main-Bold.woff2
99cd42a3c072d918f2f44984a807cf7aa16e13545fd0875fc07c6c65f99e715b fonts/KaTeX_Main-BoldItalic.woff2
97479ca6cce906abc961ecac96faa5f9ca2e61b8e7670d475826bcdee9a7c267 fonts/KaTeX_Main-Italic.woff2
c2342cd8b869e01752a9321dc17213fc40d4d04c79688c1d43f2cf316abd7866 fonts/KaTeX_Main-Regular.woff2
dc47344dbb6cb5b655c8460d561f4df5f501b90c804ad3c6cec65fe322351ab1 fonts/KaTeX_Math-BoldItalic.woff2
7af58c5ec8f132a2ddde9027c6d7814decce4d3b822a11192a42a20e2e973264 fonts/KaTeX_Math-Italic.woff2
e99ae51144bf1232efcc1bfe5add36262c6866b0faab24fa75740e1b98577a62 fonts/KaTeX_SansSerif-Bold.woff2
00b26ac825e2095056396e0553b8ac26d3f8ad158c3826e28b4c45b385c4714a fonts/KaTeX_SansSerif-Italic.woff2
68e8c73ef42afd3ccec58bf0fba302cce448938e7fc020a5e31f8a952eee1342 fonts/KaTeX_SansSerif-Regular.woff2
036d4e95149b69ff9bcc0cd55771efeb25ffa3947293e69acd78d5ac328c684b fonts/KaTeX_Script-Regular.woff2
6b47c40166b6dbe21a5dfca7718413f2147fd2399be1ba605d8ad39cedf25dfe fonts/KaTeX_Size1-Regular.woff2
d04c54219f9eaec6d4d4fd42dfb28785975a4794d6b2fc71e566b9cd6db842dd fonts/KaTeX_Size2-Regular.woff2
73d591271b1604960cb10bb90fee021670af7297017e0e98480b332d11f51995 fonts/KaTeX_Size3-Regular.woff2
a4af7d414440a1c1790825cfb700cf9cf43b0f2c4b04f0ebc523011ad9853ec0 fonts/KaTeX_Size4-Regular.woff2
71d517d67827787cfabdf186914cc3358eda539e37931941f2b2fd4a21f68c0b fonts/KaTeX_Typewriter-Regular.woff2
e73bea6d899a3f89595d37f5713cb5ddabc4084fc428a5931782628ee14799e2 index.html
a34ad8fc188e8f5a3af7ceaa2a58d7210c6c9171335a15bff2b48ebcd6a6f5b0 katex.min.css
45fbe318fea878fdc0a111913dc1f87894b2c439360d0228c086ef313f213efc katex.min.js
3cefe6fba565e189311b29265eb842484a637e1a80b0b5e766ab05bfc9ad452f renderer.js
```

Binary file not shown.

View file

@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'"
>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="katex.min.css">
<style>
html, body { margin: 0; padding: 0; overflow: hidden; background: transparent; }
#math { box-sizing: border-box; display: inline-block; min-width: 100%; width: max-content; padding: 2px 1px; }
.katex-display { margin: 0; }
</style>
<script defer src="katex.min.js"></script>
<script defer src="renderer.js"></script>
</head>
<body><div id="math"></div></body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,34 @@
"use strict";
window.renderMath = async (job) => {
const container = document.getElementById("math");
try {
document.body.style.width = `${job.widthCssPx}px`;
container.style.color = job.color;
container.style.fontSize = `${job.fontSizeCssPx}px`;
container.replaceChildren();
katex.render(job.latex, container, {
displayMode: true,
maxExpand: 1000,
maxSize: 10,
strict: "ignore",
throwOnError: true,
trust: false,
});
await document.fonts.ready;
await new Promise((resolve) => setTimeout(resolve, 0));
const initialBounds = container.getBoundingClientRect();
const width = Math.ceil(Math.max(initialBounds.width, container.scrollWidth));
document.body.style.width = `${width}px`;
await new Promise((resolve) => setTimeout(resolve, 0));
const finalBounds = container.getBoundingClientRect();
const height = Math.ceil(Math.max(finalBounds.height, container.scrollHeight));
window.ChatMathBridge.postMessage(
JSON.stringify({ id: job.id, widthCssPx: width, heightCssPx: height, success: true }),
);
} catch (_) {
window.ChatMathBridge.postMessage(
JSON.stringify({ id: job.id, widthCssPx: 0, heightCssPx: 0, success: false }),
);
}
};

View file

@ -0,0 +1,40 @@
package ai.openclaw.app
import android.content.res.AssetManager
internal const val ANDROID_LICENSE_ASSET_DIRECTORY = "openclaw/licenses"
internal data class AndroidLicenseNotice(
val title: String,
val fileName: String,
val text: String,
)
internal fun loadAndroidLicenseNotices(assetManager: AssetManager): List<AndroidLicenseNotice> {
val files =
assetManager
.list(ANDROID_LICENSE_ASSET_DIRECTORY)
.orEmpty()
.filter(::isAndroidLicenseFileName)
return files
.map { fileName ->
val rawText =
assetManager
.open("$ANDROID_LICENSE_ASSET_DIRECTORY/$fileName")
.bufferedReader(Charsets.UTF_8)
.use { reader -> reader.readText() }
AndroidLicenseNotice(title = androidLicenseTitleFromFileName(fileName), fileName = fileName, text = rawText)
}.sortedWith(
compareBy<AndroidLicenseNotice, String>(String.CASE_INSENSITIVE_ORDER) { notice -> notice.title }
.thenBy(String.CASE_INSENSITIVE_ORDER) { notice -> notice.fileName },
)
}
internal fun isAndroidLicenseFileName(fileName: String): Boolean = fileName.endsWith(".txt", ignoreCase = true)
internal fun androidLicenseTitleFromFileName(fileName: String): String =
fileName
.substringBeforeLast('.')
.trim()
.ifBlank { "License" }

View file

@ -0,0 +1,436 @@
package ai.openclaw.app
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object AndroidScreenshotFixture {
@Volatile private var scene: AndroidScreenshotScene = AndroidScreenshotScene.Home
fun configure(scene: AndroidScreenshotScene) {
this.scene = scene
}
const val gatewayId = "android-screenshot-gateway"
const val mainSessionKey = "agent:main:node-screenshot"
const val primarySessionTitle = "Android release planning"
const val cronJobId = "android-release-digest"
const val cronJobName = "Android release digest"
val agents =
listOf(
GatewayAgentSummary(
id = "main",
name = "Molty",
emoji = "M",
),
)
val models =
listOf(
GatewayModelSummary(
id = "gpt-5.2",
name = "GPT-5.2",
provider = "openai",
available = true,
supportsVision = true,
supportsAudio = true,
supportsVideo = true,
supportsDocuments = true,
supportsReasoning = true,
contextTokens = 200_000,
),
)
val providers =
listOf(
GatewayModelProviderSummary(
id = "openai",
displayName = "OpenAI",
status = "ready",
profileCount = 1,
),
)
val nodes =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-screenshot",
displayName = "Pixel",
remoteIp = "100.64.0.24",
version = BuildConfig.VERSION_NAME,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = listOf("camera", "location", "notifications"),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
val channels =
GatewayChannelsSummary(
updatedAtMs = 1_783_555_200_000,
channels =
listOf(
GatewayChannelSummary(
id = "discord",
label = "Discord",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
fun request(
method: String,
paramsJson: String?,
): String =
when (method) {
"health" -> buildJsonObject { put("ok", JsonPrimitive(true)) }.toString()
"chat.history" -> chatHistory()
"sessions.list" -> sessionList(paramsJson)
"chat.metadata" -> chatMetadata()
"cron.list" -> cronList()
"cron.get" -> cronJob().toString()
"cron.runs" -> cronRuns()
"openclaw.chat" -> systemAgentChat(paramsJson)
else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson")
}
private fun systemAgentChat(paramsJson: String?): String {
val message =
paramsJson
?.let { Json.parseToJsonElement(it).jsonObject["message"] }
?.jsonPrimitive
?.contentOrNull
return buildJsonObject {
put("sessionId", JsonPrimitive("android-screenshot-openclaw"))
put(
"reply",
JsonPrimitive(
if (message == null) {
"I can check Gateway status, repair configuration, change models, or connect channels."
} else {
"Ill keep this conversation separate from ordinary agent chat."
},
),
)
put("action", JsonPrimitive("none"))
if (message == null) {
put(
"question",
buildJsonObject {
put("id", JsonPrimitive("help"))
put("header", JsonPrimitive("OpenClaw"))
put("question", JsonPrimitive("What should we look at first?"))
put(
"options",
buildJsonArray {
add(
buildJsonObject {
put("label", JsonPrimitive("Check status"))
put("description", JsonPrimitive("Review the Gateway and active services."))
put("recommended", JsonPrimitive(true))
put("reply", JsonPrimitive("Check Gateway status"))
},
)
add(
buildJsonObject {
put("label", JsonPrimitive("Review setup"))
put("description", JsonPrimitive("Inspect models, channels, and configuration."))
put("reply", JsonPrimitive("Review setup"))
},
)
},
)
},
)
}
}.toString()
}
private fun cronList(): String =
buildJsonObject {
put(
"jobs",
buildJsonArray {
add(cronJob())
},
)
}.toString()
private fun cronJob() =
buildJsonObject {
put("id", JsonPrimitive(cronJobId))
put("name", JsonPrimitive(cronJobName))
put("enabled", JsonPrimitive(true))
put("createdAtMs", JsonPrimitive(1_783_468_800_000))
put("updatedAtMs", JsonPrimitive(1_783_555_200_000))
put("configRevision", JsonPrimitive("sha256:screenshot-fixture"))
put(
"schedule",
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(86_400_000))
put("anchorMs", JsonPrimitive(1_783_468_800_000))
},
)
put("sessionTarget", JsonPrimitive("isolated"))
put("wakeMode", JsonPrimitive("now"))
put(
"payload",
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
put("message", JsonPrimitive("Summarize Android release readiness."))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
put(
"state",
buildJsonObject {
put("nextRunAtMs", JsonPrimitive(1_783_641_600_000))
put("lastRunAtMs", JsonPrimitive(1_783_555_200_000))
put("lastStatus", JsonPrimitive("ok"))
put("lastDurationMs", JsonPrimitive(1_842))
put("consecutiveErrors", JsonPrimitive(0))
put("consecutiveSkipped", JsonPrimitive(0))
put("lastDeliveryStatus", JsonPrimitive("delivered"))
},
)
}
private fun cronRuns(): String =
buildJsonObject {
put(
"entries",
buildJsonArray {
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_555_200_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-2"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("ok"))
put("summary", JsonPrimitive("Release checklist ready"))
put("durationMs", JsonPrimitive(1_842))
put("deliveryStatus", JsonPrimitive("delivered"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_468_800_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-1"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("error"))
put("error", JsonPrimitive("Play publish blocked"))
put("durationMs", JsonPrimitive(927))
put("deliveryStatus", JsonPrimitive("not-requested"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
},
)
}.toString()
private fun chatHistory(): String =
buildJsonObject {
put("sessionId", JsonPrimitive("screenshot-session"))
put("thinkingLevel", JsonPrimitive("low"))
put(
"messages",
buildJsonArray {
add(chatMessage("user", "What is blocking the Android release?", 1_783_555_020_000))
add(
chatMessage(
"assistant",
"Two review threads are still open on the release branch, and the localization sync needs one more pass. " +
"Once those land, the changelog draft is ready for review and the tag can go out.",
1_783_555_080_000,
),
)
add(chatMessage("user", "Summarize the open review feedback for me.", 1_783_555_140_000))
add(
chatMessage(
"assistant",
"The main thread asks for a regression test around session restore, and the second one wants the new " +
"config key documented before merge. Both are small; I can draft patches for each if you want.",
1_783_555_200_000,
),
)
add(chatMessage("user", "Draft a short status update for the team.", 1_783_555_260_000))
add(
chatMessage(
"assistant",
"The Android release is close. Two review follow-ups and one localization pass remain; once those land, " +
"the changelog can be reviewed and the tag can go out.",
1_783_555_320_000,
),
)
},
)
put(
"sessionInfo",
buildJsonObject {
put("key", JsonPrimitive(mainSessionKey))
put("displayName", JsonPrimitive("New chat"))
put("updatedAt", JsonPrimitive(1_783_555_320_000))
put("unread", JsonPrimitive(false))
put("modelProvider", JsonPrimitive("openai"))
put("model", JsonPrimitive("gpt-5.2"))
put("contextTokens", JsonPrimitive(200_000))
},
)
}.toString()
private fun chatMessage(
role: String,
content: String,
timestamp: Long,
) = buildJsonObject {
put("role", JsonPrimitive(role))
put("content", JsonPrimitive(content))
put("timestamp", JsonPrimitive(timestamp))
}
private fun sessionList(paramsJson: String?): String {
val spawnedBy =
paramsJson
?.let {
runCatching {
Json
.parseToJsonElement(it)
.jsonObject["spawnedBy"]
?.jsonPrimitive
?.contentOrNull
}.getOrNull()
}
if (scene == AndroidScreenshotScene.Swarm && spawnedBy != null) {
val children = swarmChildren(spawnedBy)
return buildJsonObject {
put("sessions", buildJsonArray { children.forEach(::add) })
put("count", JsonPrimitive(children.size))
put("totalCount", JsonPrimitive(children.size))
put("hasMore", JsonPrimitive(false))
}.toString()
}
return buildJsonObject {
put(
"sessions",
buildJsonArray {
add(session("discord:release-planning", primarySessionTitle, 1_783_555_200_000))
add(session("main", "Product notes", 1_783_468_800_000))
add(session("discord:android", "Android QA", 1_783_382_400_000))
},
)
put("totalCount", JsonPrimitive(3))
}.toString()
}
private fun swarmChildren(parentKey: String) =
listOf(
swarmChild("research-polling", "National polling", "done", parentKey),
swarmChild("research-work", "Work and labor", "running", parentKey),
swarmChild("research-health", "Health", "running", parentKey),
swarmChild("research-trust", "Governance and trust", null, parentKey, queued = true),
swarmChild("research-media", "Media signals", "failed", parentKey),
)
private fun swarmChild(
key: String,
label: String,
status: String?,
parentKey: String,
queued: Boolean = false,
) = session("agent:main:subagent:$key", label, 1_783_555_320_000).toMutableMap().let { values ->
buildJsonObject {
values.forEach { (field, value) -> put(field, value) }
put("parentSessionKey", JsonPrimitive(parentKey))
put("spawnedBy", JsonPrimitive(parentKey))
put("swarmGroupId", JsonPrimitive("swarm:$parentKey:research"))
put("swarmPhase", JsonPrimitive("Research"))
put("swarmPhaseRank", JsonPrimitive(0))
put("swarmLog", JsonPrimitive("Comparing labor, education, health, trust, and media signals."))
status?.let { put("status", JsonPrimitive(it)) }
if (queued) put("subagentRunState", JsonPrimitive("active"))
if (status == "running") put("hasActiveRun", JsonPrimitive(true))
}
}
private fun session(
key: String,
displayName: String,
updatedAt: Long,
) = buildJsonObject {
put("key", JsonPrimitive(key))
put("displayName", JsonPrimitive(displayName))
put("updatedAt", JsonPrimitive(updatedAt))
put("lastActivityAt", JsonPrimitive(updatedAt))
put("unread", JsonPrimitive(false))
put("archived", JsonPrimitive(false))
put("category", JsonNull)
put("modelProvider", JsonPrimitive("openai"))
put("model", JsonPrimitive("gpt-5.2"))
put("totalTokens", JsonPrimitive(18_420))
put("contextTokens", JsonPrimitive(200_000))
}
private fun chatMetadata(): String =
buildJsonObject {
put("swarmEnabled", JsonPrimitive(scene == AndroidScreenshotScene.Swarm))
put(
"commands",
buildJsonArray {
add(
buildJsonObject {
put("name", JsonPrimitive("status"))
put("description", JsonPrimitive("Show current OpenClaw status"))
put("acceptsArgs", JsonPrimitive(false))
},
)
},
)
put(
"models",
buildJsonArray {
add(
buildJsonObject {
put("id", JsonPrimitive("gpt-5.2"))
put("name", JsonPrimitive("GPT-5.2"))
put("provider", JsonPrimitive("openai"))
put("available", JsonPrimitive(true))
put("reasoning", JsonPrimitive(true))
put("contextWindow", JsonPrimitive(200_000))
put(
"input",
buildJsonArray {
add(JsonPrimitive("text"))
add(JsonPrimitive("image"))
add(JsonPrimitive("audio"))
add(JsonPrimitive("document"))
},
)
},
)
},
)
}.toString()
}

View file

@ -0,0 +1,33 @@
package ai.openclaw.app
import ai.openclaw.app.ui.SettingsRoute
import android.content.Intent
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
enum class AndroidScreenshotScene(
val rawValue: String,
val homeDestination: HomeDestination,
internal val settingsRoute: SettingsRoute? = null,
) {
Home("home", HomeDestination.Connect),
Chat("chat", HomeDestination.Chat),
Swarm("swarm", HomeDestination.Chat),
Settings("settings", HomeDestination.Settings),
Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway),
OpenClaw("openclaw", HomeDestination.Settings, SettingsRoute.SystemAgent),
VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice),
;
companion object {
fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Home
}
}
fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? {
if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) {
return null
}
return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene))
}

View file

@ -0,0 +1,98 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import android.content.Context
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.LocaleListCompat
import java.util.Locale
/** Keep these tags aligned with androidResources.localeFilters so Android never offers an unsupported locale. */
internal enum class AppLanguage(
val languageTag: String?,
val displayName: String,
) {
System(languageTag = null, displayName = "System"),
English(languageTag = "en", displayName = "English"),
Arabic(languageTag = "ar", displayName = "العربية"),
German(languageTag = "de", displayName = "Deutsch"),
Spanish(languageTag = "es", displayName = "Español"),
Persian(languageTag = "fa", displayName = "فارسی"),
French(languageTag = "fr", displayName = "Français"),
Hindi(languageTag = "hi", displayName = "हिन्दी"),
Indonesian(languageTag = "id", displayName = "Bahasa Indonesia"),
Italian(languageTag = "it", displayName = "Italiano"),
Japanese(languageTag = "ja", displayName = "日本語"),
Korean(languageTag = "ko", displayName = "한국어"),
Dutch(languageTag = "nl", displayName = "Nederlands"),
Polish(languageTag = "pl", displayName = "Polski"),
PortugueseBrazil(languageTag = "pt-BR", displayName = "Português (Brasil)"),
Russian(languageTag = "ru", displayName = "Русский"),
Swedish(languageTag = "sv", displayName = "Svenska"),
Thai(languageTag = "th", displayName = "ไทย"),
Turkish(languageTag = "tr", displayName = "Türkçe"),
Ukrainian(languageTag = "uk", displayName = "Українська"),
Vietnamese(languageTag = "vi", displayName = "Tiếng Việt"),
ChineseSimplified(languageTag = "zh-CN", displayName = "简体中文"),
ChineseTraditional(languageTag = "zh-TW", displayName = "繁體中文"),
;
companion object {
fun fromLanguageTag(languageTag: String?): AppLanguage {
val locale = languageTag?.trim()?.takeIf(String::isNotEmpty)?.let(Locale::forLanguageTag)
return locale?.let(::fromLocale) ?: System
}
internal fun fromLocale(locale: Locale): AppLanguage? {
val exactTag = locale.toLanguageTag()
val exactMatch = entries.firstOrNull { language -> language.languageTag?.equals(exactTag, ignoreCase = true) == true }
if (exactMatch != null) return exactMatch
return entries.firstOrNull { language ->
val supportedLocale = language.languageTag?.let(Locale::forLanguageTag) ?: return@firstOrNull false
LocaleListCompat.matchesLanguageAndScript(locale, supportedLocale)
}
}
}
}
internal fun appLanguageFromLocales(locales: LocaleListCompat): AppLanguage =
if (locales.isEmpty) {
AppLanguage.System
} else {
(0 until locales.size()).firstNotNullOfOrNull { index -> locales[index]?.let(AppLanguage::fromLocale) }
?: AppLanguage.System
}
internal fun currentAppLanguage(): AppLanguage = appLanguageFromLocales(AppCompatDelegate.getApplicationLocales())
internal fun localesForAppLanguage(language: AppLanguage): LocaleListCompat = language.languageTag?.let(LocaleListCompat::forLanguageTags) ?: LocaleListCompat.getEmptyLocaleList()
internal fun setAppLanguage(language: AppLanguage) {
val locales = localesForAppLanguage(language)
NativeStringResources.setApplicationLocales(locales)
if (locales != AppCompatDelegate.getApplicationLocales()) {
AppCompatDelegate.setApplicationLocales(locales)
notifyNativeLocaleChanged()
}
}
internal fun currentSystemLanguageTag(context: Context): String {
val systemLocales = LocaleManagerCompat.getSystemLocales(context)
val locale = systemLocales[0] ?: Resources.getSystem().configuration.locales[0]
return locale.toLanguageTag()
}
internal fun appLanguageRowSubtitle(
language: AppLanguage,
systemLanguageTag: String,
): String {
val languageTag = language.languageTag
if (languageTag != null) {
return nativeString("OpenClaw translations · \$languageTag", languageTag)
}
return nativeString("Follow Android · \$systemLanguageTag", systemLanguageTag)
}

View file

@ -0,0 +1,25 @@
package ai.openclaw.app
/** User-selectable app theme mode for Android appearance settings. */
enum class AppearanceThemeMode(
val rawValue: String,
val displayLabel: String,
) {
System(rawValue = "system", displayLabel = "System"),
Dark(rawValue = "dark", displayLabel = "Dark"),
Light(rawValue = "light", displayLabel = "Light"),
;
fun isDark(systemDark: Boolean): Boolean =
when (this) {
System -> systemDark
Dark -> true
Light -> false
}
companion object {
fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark
fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark
}
}

View file

@ -0,0 +1,223 @@
package ai.openclaw.app
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import androidx.core.content.IntentCompat
import java.util.Locale
/** Android Assistant entry point used by manifest-declared app actions. */
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
/** Debug action that opens the Voice tab directly for Android E2E automation. */
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
/** Intent extra that carries an optional assistant prompt for app actions. */
const val extraAssistantPrompt = "prompt"
/**
* Top-level home destinations that external actions may request.
*/
enum class HomeDestination {
Connect,
Chat,
Voice,
Screen,
Settings,
}
/**
* Normalized launch request from Android Assistant or explicit app actions.
*/
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
val autoSend: Boolean,
)
/** Shared content staged in chat for user review before sending. */
data class ShareLaunchRequest(
val text: String?,
val attachments: List<SharedAttachment>,
val droppedAttachmentCount: Int,
)
enum class SharedAttachmentKind {
Image,
Audio,
Video,
Document,
}
data class SharedAttachment(
val uri: Uri,
val kind: SharedAttachmentKind,
val mimeType: String,
)
private data class SharedAttachmentSelection(
val attachments: List<SharedAttachment>,
val droppedCount: Int,
)
internal val SHARED_ATTACHMENT_MIME_ALLOWLIST =
setOf(
"image/*",
"audio/*",
"video/*",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/csv",
"text/markdown",
)
internal val SHARED_AUDIO_DOCUMENT_MIME_TYPES =
SHARED_ATTACHMENT_MIME_ALLOWLIST.filterNot { it == "image/*" || it == "video/*" }.toTypedArray()
internal val SHARED_VIDEO_MIME_TYPES = arrayOf("video/*")
/**
* Parses app-owned navigation actions that should open a specific home tab.
*/
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
val action = intent?.action ?: return null
return when {
// Debug-only shortcut keeps E2E navigation out of release builds.
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
else -> null
}
}
/**
* Parse external assistant entry points without starting any UI side effects.
*/
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {
Intent.ACTION_ASSIST ->
AssistantLaunchRequest(
source = "assist",
prompt = null,
autoSend = false,
)
actionAskOpenClaw -> {
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
AssistantLaunchRequest(
source = "app_action",
prompt = prompt,
autoSend = false,
)
}
else -> null
}
}
/** Parses Android Sharesheet metadata without opening or reading shared payload bytes. */
fun parseShareLaunchIntent(
intent: Intent?,
resolveMimeType: (Uri) -> String?,
): ShareLaunchRequest? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val text =
listOf(intent.getStringExtra(Intent.EXTRA_SUBJECT), intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString())
.mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } }
.distinct()
.joinToString(separator = "\n\n")
.ifEmpty { null }
val attachmentSelection = sharedAttachments(intent, action, resolveMimeType)
if (text == null && attachmentSelection.attachments.isEmpty() && attachmentSelection.droppedCount == 0) return null
return ShareLaunchRequest(
text = text,
attachments = attachmentSelection.attachments,
droppedAttachmentCount = attachmentSelection.droppedCount,
)
}
private fun sharedAttachments(
intent: Intent,
action: String,
resolveMimeType: (Uri) -> String?,
): SharedAttachmentSelection {
val streamUris =
when (action) {
Intent.ACTION_SEND ->
listOfNotNull(IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java))
Intent.ACTION_SEND_MULTIPLE ->
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
else -> emptyList()
}
val clipUris =
intent.clipData
?.let { clip ->
(0 until clip.itemCount).mapNotNull { index -> clip.getItemAt(index).uri }
}.orEmpty()
// Only provider-backed content URIs use the sender's temporary read grant. Rejecting file://
// prevents an external intent from turning OpenClaw into a reader for its own private files.
val validUris =
(streamUris + clipUris)
.filter { uri -> uri.scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) }
.distinct()
val fallbackMimeType =
normalizeSharedAttachmentMimeType(intent.type)
?.takeIf(::isStageableSharedAttachmentMimeType)
val resolved = mutableListOf<SharedAttachment>()
var droppedCount = 0
for ((index, uri) in validUris.withIndex()) {
if (resolved.size >= MAX_SHARED_ATTACHMENT_COUNT) {
droppedCount += validUris.size - index
break
}
val providerMimeType =
try {
normalizeSharedAttachmentMimeType(resolveMimeType(uri))
} catch (_: Exception) {
null
}
val mimeType = providerMimeType ?: fallbackMimeType
val kind = sharedAttachmentKindForMimeType(mimeType)
if (!isStageableSharedAttachmentMimeType(mimeType) || kind == null) {
droppedCount += 1
continue
}
resolved += SharedAttachment(uri = uri, kind = kind, mimeType = requireNotNull(mimeType))
}
return SharedAttachmentSelection(
attachments = resolved,
droppedCount = droppedCount,
)
}
internal fun sharedAttachmentKindForMimeType(mimeType: String?): SharedAttachmentKind? {
val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return null
return when {
normalized.startsWith("image/") -> SharedAttachmentKind.Image
normalized.startsWith("audio/") -> SharedAttachmentKind.Audio
normalized.startsWith("video/") -> SharedAttachmentKind.Video
normalized in SHARED_ATTACHMENT_MIME_ALLOWLIST -> SharedAttachmentKind.Document
else -> null
}
}
internal fun isStageableSharedAttachmentMimeType(mimeType: String?): Boolean {
val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return false
val kind = sharedAttachmentKindForMimeType(normalized) ?: return false
return kind == SharedAttachmentKind.Image || !normalized.endsWith("/*")
}
internal fun normalizeSharedAttachmentMimeType(mimeType: String?): String? =
mimeType
?.substringBefore(';')
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotEmpty() }
private const val MAX_SHARED_ATTACHMENT_COUNT = 8

View file

@ -0,0 +1,16 @@
package ai.openclaw.app
/** Camera HUD state categories shown over the Android UI during capture. */
enum class CameraHudKind {
Photo,
Recording,
Success,
Error,
}
/** One-shot camera HUD message keyed by token so repeated text still replays. */
data class CameraHudState(
val token: Long,
val kind: CameraHudKind,
val message: String,
)

View file

@ -0,0 +1,316 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
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
data class GatewayCronJobDetail(
val id: String,
val name: String,
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val scheduleKind: String,
val scheduleLabel: NativeText,
val scheduleDetail: NativeText,
val scheduleAt: String?,
val scheduleEveryMs: Long?,
val scheduleAnchorMs: Long?,
val scheduleCronExpr: String?,
val scheduleTimezone: String?,
val scheduleStaggerMs: Long?,
val scheduleCommand: String?,
val scheduleCwd: String?,
val sessionTarget: String,
val wakeMode: String,
val payloadKind: String,
val payloadText: String?,
val payloadLabel: NativeText,
val payloadModel: String?,
val payloadThinking: String?,
val payloadCommandArgv: List<String>?,
val payloadCommandCwd: String?,
val deliveryLabel: NativeText,
val failureAlertLabel: NativeText,
val createdAtMs: Long,
val updatedAtMs: Long,
val configRevision: String?,
val nextRunAtMs: Long?,
val runningAtMs: Long?,
val lastRunAtMs: Long?,
val lastRunStatus: String?,
val lastError: String?,
val lastDiagnosticSummary: String?,
val lastDurationMs: Long?,
val consecutiveErrors: Long?,
val consecutiveSkipped: Long?,
val lastDeliveryStatus: String?,
val lastDeliveryError: String?,
)
sealed interface GatewayCronJobDetailState {
data object Idle : GatewayCronJobDetailState
data class Loading(
val id: String,
) : GatewayCronJobDetailState
data class Loaded(
val job: GatewayCronJobDetail,
) : GatewayCronJobDetailState
data class Error(
val id: String,
val message: NativeText,
) : GatewayCronJobDetailState
}
internal data class CronJobDetailRequest(
val id: String,
val generation: Long,
)
/** Couples the selected job id to its generation so older RPCs cannot publish into a new screen. */
internal class CronJobDetailRequestGuard {
private val lock = Any()
private var generation = 0L
private var selectedId: String? = null
fun begin(rawId: String): CronJobDetailRequest? {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null
return synchronized(lock) {
generation += 1
selectedId = id
CronJobDetailRequest(id = id, generation = generation)
}
}
fun beginIfCurrent(
rawId: String,
onBegin: (CronJobDetailRequest) -> Unit,
): CronJobDetailRequest? {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null
return synchronized(lock) {
if (selectedId != id) return@synchronized null
generation += 1
CronJobDetailRequest(id = id, generation = generation).also(onBegin)
}
}
fun cancel(onCancel: () -> Unit = {}) {
synchronized(lock) {
generation += 1
selectedId = null
onCancel()
}
}
fun cancelIfCurrent(
rawId: String,
onCancel: () -> Unit,
): Boolean {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (selectedId != id) return@synchronized false
generation += 1
selectedId = null
onCancel()
true
}
}
fun publishIfCurrent(
request: CronJobDetailRequest,
publish: () -> Unit,
): Boolean =
synchronized(lock) {
if (request.generation != generation || request.id != selectedId) return@synchronized false
publish()
true
}
}
internal fun cronJobGetParams(id: String): String =
buildJsonObject {
put("id", JsonPrimitive(id))
}.toString()
internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail? {
val value = job ?: return null
val id = value.string("id") ?: return null
val name = value.string("name") ?: return null
val createdAtMs = value.long("createdAtMs") ?: return null
val updatedAtMs = value.long("updatedAtMs") ?: return null
val schedule = value["schedule"].asObjectOrNull() ?: return null
val payload = value["payload"].asObjectOrNull() ?: return null
val sessionTarget = value.string("sessionTarget") ?: return null
val wakeMode = value.string("wakeMode") ?: return null
val payloadKind = payload.string("kind") ?: return null
val scheduleKind = schedule.string("kind") ?: return null
if (scheduleKind !in setOf("at", "every", "cron", "on-exit")) return null
if (payloadKind !in setOf("systemEvent", "agentTurn", "command", "script")) return null
val state = value["state"].asObjectOrNull() ?: return null
return GatewayCronJobDetail(
id = id,
name = name,
description = value.string("description").orEmpty(),
enabled = value.boolean("enabled"),
deleteAfterRun = value.boolean("deleteAfterRun"),
scheduleKind = scheduleKind,
scheduleLabel = cronScheduleLabel(schedule),
scheduleDetail = cronScheduleDetail(schedule),
scheduleAt = schedule.string("at"),
scheduleEveryMs = schedule.long("everyMs"),
scheduleAnchorMs = schedule.long("anchorMs"),
scheduleCronExpr = schedule.string("expr"),
scheduleTimezone = schedule.string("tz"),
scheduleStaggerMs = schedule.long("staggerMs"),
scheduleCommand = schedule.string("command"),
scheduleCwd = schedule.string("cwd"),
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payloadKind = payloadKind,
payloadText = cronPayloadText(payload),
payloadLabel = cronPayloadLabel(payload),
payloadModel = payload.string("model"),
payloadThinking = payload.string("thinking"),
payloadCommandArgv =
(payload["argv"] as? JsonArray)
?.mapNotNull { it.asStringOrNull() },
payloadCommandCwd = payload.string("cwd"),
deliveryLabel = cronDeliveryLabel(value["delivery"].asObjectOrNull()),
failureAlertLabel = cronFailureAlertLabel(value["failureAlert"]),
createdAtMs = createdAtMs,
updatedAtMs = updatedAtMs,
configRevision = value.string("configRevision"),
nextRunAtMs = state.long("nextRunAtMs"),
runningAtMs = state.long("runningAtMs"),
lastRunAtMs = state.long("lastRunAtMs"),
lastRunStatus = cronJobLastRunStatus(state),
lastError = state.string("lastError"),
lastDiagnosticSummary = state.string("lastDiagnosticSummary"),
lastDurationMs = state.long("lastDurationMs"),
consecutiveErrors = state.long("consecutiveErrors"),
consecutiveSkipped = state.long("consecutiveSkipped"),
lastDeliveryStatus = state.string("lastDeliveryStatus"),
lastDeliveryError = state.string("lastDeliveryError"),
)
}
internal fun formatCronInterval(everyMs: Long): NativeText {
val minutes = everyMs / 60_000L
val hours = minutes / 60L
val days = hours / 24L
return when {
days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days)
hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours)
minutes >= 1 -> nativeText("Every \${minutes}m", minutes)
else -> nativeText("Repeating")
}
}
private fun cronScheduleLabel(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> nativeText("One time")
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
"cron" -> schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
else -> nativeText("Scheduled")
}
private fun cronScheduleDetail(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> schedule.string("at")?.let(::verbatimText) ?: nativeText("One time")
"every" -> {
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
val anchor = schedule.long("anchorMs")?.let { nativeText("Anchor \$it", it) }
joinedNativeText(" · ", listOfNotNull(every, anchor))
}
"cron" -> {
val expression = schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
val timezone = schedule.string("tz")?.let(::verbatimText)
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeText("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) }
joinedNativeText(" · ", listOfNotNull(expression, timezone, stagger))
}
else -> nativeText("Scheduled")
}
private fun cronPayloadText(payload: JsonObject): String? =
when (payload.string("kind")) {
"systemEvent" -> payload.string("text")
"agentTurn" -> payload.string("message")
"command" ->
(payload["argv"] as? JsonArray)
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
?.joinToString(" ")
"script" -> payload["script"].asStringOrNull()
else -> null
}
private fun cronPayloadLabel(payload: JsonObject): NativeText =
when (payload.string("kind")) {
"systemEvent" -> nativeText("System event")
"agentTurn" -> {
val model = payload.string("model")?.let(::verbatimText)
val thinking = payload.string("thinking")?.let { nativeText("Thinking \$it", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Agent turn"), model, thinking))
}
"command" -> nativeText("Command")
"script" -> {
val timeout = payload.long("timeoutSeconds")?.let { nativeText("Timeout \${it}s", it) }
val budget = payload.long("toolBudget")?.let { nativeText("\$it tools", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Script"), timeout, budget))
}
else -> nativeText("Payload")
}
private fun cronDeliveryLabel(delivery: JsonObject?): NativeText {
val value = delivery ?: return nativeText("Default")
val mode = value.string("mode") ?: return nativeText("Default")
return joinedNativeText(
" · ",
listOfNotNull(
verbatimText(mode.replaceFirstChar { it.uppercaseChar() }),
value.string("channel")?.let(::verbatimText),
value.string("to")?.let(::verbatimText),
value.string("accountId")?.let { nativeText("Account \$it", it) },
),
)
}
private fun cronFailureAlertLabel(failureAlert: JsonElement?): NativeText {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeText("Off")
val alert = failureAlert.asObjectOrNull() ?: return nativeText("Default")
val parts =
listOfNotNull(
alert.long("after")?.let { nativeText("After \$it", it) },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() }?.let(::verbatimText),
alert.string("channel")?.let(::verbatimText),
alert.string("to")?.let(::verbatimText),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeText("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) },
)
return if (parts.isEmpty()) nativeText("On") else joinedNativeText(" · ", parts)
}
private fun JsonObject.string(key: String): String? =
this[key]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun JsonObject.long(key: String): Long? =
(this[key] as? JsonPrimitive)
?.content
?.trim()
?.toLongOrNull()
private fun JsonObject.boolean(key: String): Boolean = (this[key] as? JsonPrimitive)?.booleanOrNull == true

View file

@ -0,0 +1,671 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
data class GatewayCronRunSummary(
val ts: Long,
val runId: String?,
val status: String?,
val summary: String?,
val error: String?,
val durationMs: Long?,
val deliveryStatus: String?,
val sessionKey: String?,
val model: String?,
)
sealed interface GatewayCronRunHistoryState {
data object Idle : GatewayCronRunHistoryState
data class Loading(
val id: String,
) : GatewayCronRunHistoryState
data class Loaded(
val id: String,
val runs: List<GatewayCronRunSummary>,
) : GatewayCronRunHistoryState
data class Error(
val id: String,
val message: String,
) : GatewayCronRunHistoryState
}
enum class GatewayCronAction {
Run,
Enable,
Disable,
Save,
Delete,
}
enum class GatewayCronNoticeKind {
Success,
Warning,
Error,
}
sealed interface GatewayCronActionState {
data object Idle : GatewayCronActionState
data class Running(
val id: String,
val action: GatewayCronAction,
) : GatewayCronActionState
data class Notice(
val id: String,
val message: NativeText,
val kind: GatewayCronNoticeKind,
val deleted: Boolean = false,
) : GatewayCronActionState {
constructor(
id: String,
message: String,
kind: GatewayCronNoticeKind,
deleted: Boolean = false,
) : this(id = id, message = nativeText(message), kind = kind, deleted = deleted)
}
}
/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */
internal class PendingCronRunRegistry {
private val lock = Any()
private val runIdsByJob = linkedMapOf<String, String>()
fun contains(rawJobId: String): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) { runIdsByJob.containsKey(jobId) }
}
fun begin(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob.containsKey(jobId)) return@synchronized false
runIdsByJob[jobId] = runId
publish(runIdsByJob.keys.toSet())
true
}
}
fun finish(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob[jobId] != runId) return@synchronized false
runIdsByJob.remove(jobId)
publish(runIdsByJob.keys.toSet())
true
}
}
fun clear(publish: (Set<String>) -> Unit) {
synchronized(lock) {
runIdsByJob.clear()
publish(emptySet())
}
}
}
internal fun nextCronJobsPageOffset(
page: JsonObject?,
requestedOffset: Int,
pageCount: Int,
): Int? {
val responseOffset =
page
?.long("offset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: requestedOffset
val total = page?.long("total")?.takeIf { it >= 0 }
val hasMore =
(page?.get("hasMore") as? JsonPrimitive)?.booleanOrNull
?: (total != null && responseOffset.toLong() + pageCount < total)
if (!hasMore) return null
val nextOffset =
page
?.long("nextOffset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: Math.addExact(responseOffset, pageCount)
require(nextOffset > requestedOffset) { "Gateway returned a non-advancing cron jobs page." }
return nextOffset
}
sealed interface GatewayCronScheduleEdit {
data class At(
val at: String,
) : GatewayCronScheduleEdit
data class Every(
val everyMs: String,
val anchorMs: String,
) : GatewayCronScheduleEdit
data class Cron(
val expression: String,
val timezone: String,
val staggerMs: String,
) : GatewayCronScheduleEdit
data class OnExit(
val command: String,
val cwd: String,
) : GatewayCronScheduleEdit
}
sealed interface GatewayCronPayloadEdit {
data class SystemEvent(
val text: String,
) : GatewayCronPayloadEdit
data class AgentTurn(
val message: String,
val model: String,
val thinking: String,
) : GatewayCronPayloadEdit
data class Command(
val argvJson: String,
val cwd: String,
) : GatewayCronPayloadEdit
data class ReadOnlyScript(
val script: String,
) : GatewayCronPayloadEdit
}
data class GatewayCronJobEdit(
val name: String,
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val schedule: GatewayCronScheduleEdit,
val sessionTarget: String,
val wakeMode: String,
val payload: GatewayCronPayloadEdit,
) {
fun withSchedule(value: GatewayCronScheduleEdit): GatewayCronJobEdit =
copy(
schedule = value,
deleteAfterRun = deleteAfterRun && value is GatewayCronScheduleEdit.At,
)
}
internal data class CronEditorDraftState(
val baseline: GatewayCronJobEdit,
val edit: GatewayCronJobEdit,
val savePending: Boolean = false,
val saveSucceeded: Boolean = false,
val hasIncomingConflict: Boolean = false,
) {
val isDirty: Boolean
get() = edit != baseline
val requiresResolution: Boolean
get() = isDirty || hasIncomingConflict
fun withEdit(value: GatewayCronJobEdit): CronEditorDraftState = copy(edit = value)
fun saveStarted(): CronEditorDraftState = copy(savePending = true, saveSucceeded = false)
fun saveAborted(): CronEditorDraftState = copy(savePending = false, saveSucceeded = false)
fun observeSaveNotice(kind: GatewayCronNoticeKind): CronEditorDraftState {
if (!savePending) return this
return if (kind == GatewayCronNoticeKind.Success) {
copy(saveSucceeded = true)
} else {
copy(savePending = false, saveSucceeded = false)
}
}
fun observeJob(job: GatewayCronJobDetail): CronEditorDraftState {
val incoming = job.toCronJobEdit()
if (incoming == edit) {
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
if (incoming == baseline) {
return copy(hasIncomingConflict = false)
}
val canAdopt = !isDirty || saveSucceeded
if (!canAdopt) {
return copy(hasIncomingConflict = true)
}
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
companion object {
fun from(job: GatewayCronJobDetail): CronEditorDraftState {
val edit = job.toCronJobEdit()
return CronEditorDraftState(
baseline = edit,
edit = edit,
)
}
}
}
internal fun CronEditorDraftState.reconcileRestoredAction(
isConnected: Boolean,
jobId: String,
actionState: GatewayCronActionState,
): CronEditorDraftState {
if (!savePending) return this
// Activity recreation retains the runtime action; process death does not.
// Preserve pending only when the restored runtime still owns this Save.
val retainedSaveState =
when (actionState) {
is GatewayCronActionState.Running ->
actionState.id == jobId && actionState.action == GatewayCronAction.Save
is GatewayCronActionState.Notice -> actionState.id == jobId
GatewayCronActionState.Idle -> false
}
return if (isConnected && retainedSaveState) this else saveAborted()
}
internal enum class GatewayCronRunSkipReason {
NotDue,
AlreadyRunning,
RestartRecoveryPending,
InvalidSpec,
Stopped,
;
val message: String
get() = messageText.resolveNativeText()
val messageText: NativeText
get() =
when (this) {
NotDue -> nativeText("Automation is not due yet.")
AlreadyRunning -> nativeText("Automation is already running.")
RestartRecoveryPending -> nativeText("Gateway restart recovery is still in progress.")
InvalidSpec -> nativeText("Automation has an invalid configuration.")
Stopped -> nativeText("Cron scheduler is stopped.")
}
}
internal sealed interface GatewayCronRunOutcome {
data class Started(
val runId: String?,
) : GatewayCronRunOutcome
data class Skipped(
val reason: GatewayCronRunSkipReason,
) : GatewayCronRunOutcome
data object Rejected : GatewayCronRunOutcome
}
internal fun cronRunShouldRefresh(outcome: GatewayCronRunOutcome): Boolean =
when (outcome) {
is GatewayCronRunOutcome.Started -> true
is GatewayCronRunOutcome.Skipped -> outcome.reason == GatewayCronRunSkipReason.InvalidSpec
GatewayCronRunOutcome.Rejected -> false
}
internal fun cronRunCompletionNotice(
jobId: String,
status: String?,
): GatewayCronActionState.Notice {
val (message, kind) =
when (status) {
"ok" -> nativeText("Automation run finished.") to GatewayCronNoticeKind.Success
"skipped" -> nativeText("Automation run skipped.") to GatewayCronNoticeKind.Warning
"error" -> nativeText("Automation run failed.") to GatewayCronNoticeKind.Error
else -> nativeText("Automation run finished with an unknown status.") to GatewayCronNoticeKind.Warning
}
return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind)
}
internal fun isCronJobRevisionConflict(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "CRON_JOB_CHANGED"
internal fun GatewayCronJobDetail.toCronJobEdit(): GatewayCronJobEdit =
GatewayCronJobEdit(
name = name,
description = description,
enabled = enabled,
// Gateway deletion only runs after a successful one-shot schedule.
deleteAfterRun = deleteAfterRun && scheduleKind == "at",
schedule =
when (scheduleKind) {
"at" -> GatewayCronScheduleEdit.At(at = scheduleAt.orEmpty())
"every" ->
GatewayCronScheduleEdit.Every(
everyMs = scheduleEveryMs?.toString().orEmpty(),
anchorMs = scheduleAnchorMs?.toString().orEmpty(),
)
"cron" ->
GatewayCronScheduleEdit.Cron(
expression = scheduleCronExpr.orEmpty(),
timezone = scheduleTimezone.orEmpty(),
staggerMs = scheduleStaggerMs?.toString().orEmpty(),
)
"on-exit" ->
GatewayCronScheduleEdit.OnExit(
command = scheduleCommand.orEmpty(),
cwd = scheduleCwd.orEmpty(),
)
else -> error("Unsupported cron schedule kind: $scheduleKind")
},
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payload =
when (payloadKind) {
"systemEvent" -> GatewayCronPayloadEdit.SystemEvent(text = payloadText.orEmpty())
"agentTurn" ->
GatewayCronPayloadEdit.AgentTurn(
message = payloadText.orEmpty(),
model = payloadModel.orEmpty(),
thinking = payloadThinking.orEmpty(),
)
"command" ->
GatewayCronPayloadEdit.Command(
argvJson = JsonArray(payloadCommandArgv.orEmpty().map(::JsonPrimitive)).toString(),
cwd = payloadCommandCwd.orEmpty(),
)
"script" -> GatewayCronPayloadEdit.ReadOnlyScript(script = payloadText.orEmpty())
else -> error("Unsupported cron payload kind: $payloadKind")
},
)
internal fun buildCronUpdateParams(
original: GatewayCronJobDetail,
edit: GatewayCronJobEdit,
): String {
val name = edit.name.trim()
require(name.isNotEmpty()) { "Automation name is required." }
val description = edit.description.trim()
val sessionTarget = edit.sessionTarget.trim()
require(
sessionTarget == "main" ||
sessionTarget == "isolated" ||
sessionTarget == "current" ||
(sessionTarget.startsWith("session:") && sessionTarget.removePrefix("session:").isNotBlank()),
) { "Session target must be main, isolated, current, or session:<id>." }
val wakeMode = edit.wakeMode.trim()
require(wakeMode == "now" || wakeMode == "next-heartbeat") {
"Wake mode must be now or next-heartbeat."
}
val schedulePatch = buildCronSchedulePatch(original = original, edit = edit.schedule)
val payloadPatch = buildCronPayloadPatch(original = original, edit = edit.payload)
val patch =
buildJsonObject {
if (name != original.name) put("name", JsonPrimitive(name))
if (description != original.description) put("description", JsonPrimitive(description))
if (edit.enabled != original.enabled) put("enabled", JsonPrimitive(edit.enabled))
if (edit.deleteAfterRun != original.deleteAfterRun) {
put("deleteAfterRun", JsonPrimitive(edit.deleteAfterRun))
}
schedulePatch?.let { put("schedule", it) }
if (sessionTarget != original.sessionTarget) {
put("sessionTarget", JsonPrimitive(sessionTarget))
}
if (wakeMode != original.wakeMode) put("wakeMode", JsonPrimitive(wakeMode))
payloadPatch?.let { put("payload", it) }
}
require(patch.isNotEmpty()) { "No cron changes to save." }
val configRevision =
requireNotNull(original.configRevision) {
"Update the gateway before saving cron changes from Android."
}
return buildJsonObject {
put("id", JsonPrimitive(original.id))
put("expectedConfigRevision", JsonPrimitive(configRevision))
put("patch", patch)
}.toString()
}
internal fun parseGatewayCronRunOutcome(root: JsonObject?): GatewayCronRunOutcome? {
val value = root ?: return null
val ok = value.optionalBoolean("ok") ?: return null
if (!ok) return GatewayCronRunOutcome.Rejected
if (value.optionalBoolean("ran") == true) {
return GatewayCronRunOutcome.Started(runId = value.string("runId"))
}
if (value.optionalBoolean("enqueued") == true) {
val runId = value.string("runId") ?: return null
return GatewayCronRunOutcome.Started(runId = runId)
}
if (value.optionalBoolean("ran") != false) return null
val reason =
when (value.string("reason")) {
"not-due" -> GatewayCronRunSkipReason.NotDue
"already-running" -> GatewayCronRunSkipReason.AlreadyRunning
"restart-recovery-pending" -> GatewayCronRunSkipReason.RestartRecoveryPending
"invalid-spec" -> GatewayCronRunSkipReason.InvalidSpec
"stopped" -> GatewayCronRunSkipReason.Stopped
else -> return null
}
return GatewayCronRunOutcome.Skipped(reason)
}
internal fun parseGatewayCronRunHistory(entries: JsonArray?): List<GatewayCronRunSummary> =
entries
?.mapNotNull { item ->
val value = item.asObjectOrNull() ?: return@mapNotNull null
val ts = value.long("ts") ?: return@mapNotNull null
GatewayCronRunSummary(
ts = ts,
runId = value.string("runId"),
status = value.string("status"),
summary = value.string("summary"),
error = value.string("error"),
durationMs = value.long("durationMs"),
deliveryStatus = value.string("deliveryStatus"),
sessionKey = value.string("sessionKey"),
model = value.string("model"),
)
}.orEmpty()
private fun buildCronSchedulePatch(
original: GatewayCronJobDetail,
edit: GatewayCronScheduleEdit,
): JsonObject? =
when (edit) {
is GatewayCronScheduleEdit.At -> {
require(original.scheduleKind == "at") { "Changing schedule type is not supported here." }
val at = edit.at.trim()
require(at.isNotEmpty()) { "One-time automations need an ISO time." }
if (at == original.scheduleAt) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("at"))
put("at", JsonPrimitive(at))
}
}
}
is GatewayCronScheduleEdit.Every -> {
require(original.scheduleKind == "every") { "Changing schedule type is not supported here." }
val everyMs = edit.everyMs.trim().toLongOrNull()
require(everyMs != null && everyMs > 0L) { "Interval must be a positive number of milliseconds." }
val anchorMs = parseOptionalNonNegativeLong(edit.anchorMs, "Anchor")
if (everyMs == original.scheduleEveryMs && anchorMs == original.scheduleAnchorMs) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(everyMs))
anchorMs?.let { put("anchorMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.Cron -> {
require(original.scheduleKind == "cron") { "Changing schedule type is not supported here." }
val expression = edit.expression.trim()
require(expression.isNotEmpty()) { "Cron expression is required." }
val timezone = edit.timezone.trim().ifEmpty { null }
val requestedStaggerMs = parseOptionalNonNegativeLong(edit.staggerMs, "Stagger")
val staggerMs =
requestedStaggerMs ?: if (original.scheduleStaggerMs != null) 0L else null
if (
expression == original.scheduleCronExpr &&
timezone == original.scheduleTimezone &&
staggerMs == original.scheduleStaggerMs
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("cron"))
put("expr", JsonPrimitive(expression))
timezone?.let { put("tz", JsonPrimitive(it)) }
staggerMs?.let { put("staggerMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.OnExit -> {
require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." }
val command = edit.command.trim()
require(command.isNotEmpty()) { "On-exit automations need a command." }
val cwd = edit.cwd.trim().ifEmpty { null }
if (command == original.scheduleCommand && cwd == original.scheduleCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("on-exit"))
put("command", JsonPrimitive(command))
cwd?.let { put("cwd", JsonPrimitive(it)) }
}
}
}
}
private fun buildCronPayloadPatch(
original: GatewayCronJobDetail,
edit: GatewayCronPayloadEdit,
): JsonObject? =
when (edit) {
is GatewayCronPayloadEdit.SystemEvent -> {
require(original.payloadKind == "systemEvent") { "Changing payload type is not supported here." }
val text = edit.text.trim()
require(text.isNotEmpty()) { "System event text is required." }
if (text == original.payloadText) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("systemEvent"))
put("text", JsonPrimitive(text))
}
}
}
is GatewayCronPayloadEdit.AgentTurn -> {
require(original.payloadKind == "agentTurn") { "Changing payload type is not supported here." }
val message = edit.message.trim()
require(message.isNotEmpty()) { "Agent message is required." }
val model = edit.model.trim().ifEmpty { null }
val thinking = edit.thinking.trim().ifEmpty { null }
if (
message == original.payloadText &&
model == original.payloadModel &&
thinking == original.payloadThinking
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
if (message != original.payloadText) put("message", JsonPrimitive(message))
if (model != original.payloadModel) put("model", model?.let(::JsonPrimitive) ?: JsonNull)
if (thinking != original.payloadThinking) {
put("thinking", thinking?.let(::JsonPrimitive) ?: JsonNull)
}
}
}
}
is GatewayCronPayloadEdit.Command -> {
require(original.payloadKind == "command") { "Changing payload type is not supported here." }
val argv = parseCommandArgv(edit.argvJson)
val cwd = edit.cwd.trim().ifEmpty { null }
if (cwd == null && original.payloadCommandCwd != null) {
error("The gateway does not support clearing a command working directory.")
}
if (argv == original.payloadCommandArgv && cwd == original.payloadCommandCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("command"))
if (argv != original.payloadCommandArgv) {
put("argv", JsonArray(argv.map(::JsonPrimitive)))
}
if (cwd != original.payloadCommandCwd) put("cwd", JsonPrimitive(requireNotNull(cwd)))
}
}
}
is GatewayCronPayloadEdit.ReadOnlyScript -> {
require(original.payloadKind == "script" && edit.script == original.payloadText) {
"Script payloads are read-only on Android."
}
null
}
}
private fun parseCommandArgv(raw: String): List<String> {
val value =
runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonArray
?: error("Command argv must be a JSON array.")
val argv =
value.map { item ->
val primitive = item as? JsonPrimitive
primitive?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() }
?: error("Command argv entries must be non-empty strings.")
}
require(argv.isNotEmpty()) { "Command argv must contain at least one entry." }
return argv
}
private fun parseOptionalNonNegativeLong(
raw: String,
label: String,
): Long? {
val value = raw.trim()
if (value.isEmpty()) return null
val parsed = value.toLongOrNull()
require(parsed != null && parsed >= 0L) { "$label must be a non-negative number of milliseconds." }
return parsed
}
private fun JsonObject.string(key: String): String? =
this[key]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun JsonObject.long(key: String): Long? =
(this[key] as? JsonPrimitive)
?.content
?.trim()
?.toLongOrNull()
private fun JsonObject.optionalBoolean(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull

View file

@ -0,0 +1,28 @@
package ai.openclaw.app
import android.content.Context
import android.os.Build
import android.provider.Settings
object DeviceNames {
/** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()
?.trim()
.orEmpty()
if (deviceName.isNotEmpty()) return deviceName
// Manufacturer/model are best-effort platform fields; keep the final
// fallback stable so stored default names do not become blank.
val model =
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
.joinToString(" ")
.trim()
return model.ifEmpty { "Android Node" }
}
}

View file

@ -0,0 +1,41 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayAgentSummary(
val id: String,
val name: String?,
val emoji: String?,
val avatar: String? = null,
val avatarUrl: String? = null,
val workspaceGit: Boolean = false,
val kind: String? = null,
)
/** Parses validated agents.list rows into the smaller Android display model. */
internal fun parseGatewayAgentSummaries(root: JsonObject): List<GatewayAgentSummary> = (root["agents"] as? JsonArray)?.mapNotNull(::parseGatewayAgentSummary) ?: emptyList()
private fun parseGatewayAgentSummary(item: JsonElement): GatewayAgentSummary? {
val agent = item.asObjectOrNull() ?: return null
val id = agent["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val identity = agent["identity"].asObjectOrNull()
return GatewayAgentSummary(
id = id,
kind = agent["kind"].asStringOrNull().normalizedAgentValue(),
name = agent["name"].asStringOrNull().normalizedAgentValue(),
emoji = identity?.get("emoji").asStringOrNull().normalizedAgentValue(),
avatar = identity?.get("avatar").asStringOrNull().normalizedAgentValue(),
avatarUrl = identity?.get("avatarUrl").asStringOrNull().normalizedAgentValue(),
workspaceGit = (agent["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true,
)
}
internal fun List<GatewayAgentSummary>.selectableAgents(): List<GatewayAgentSummary> = filter { it.kind != "system" }
private fun String?.normalizedAgentValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() }

View file

@ -0,0 +1,587 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.concurrent.atomic.AtomicLong
data class GatewayExecApprovalSummary(
val id: String,
val commandText: NativeText,
val commandPreview: String?,
val warningText: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal enum class GatewayApprovalTerminalStatus {
Allowed,
Denied,
Expired,
Cancelled,
}
internal sealed interface GatewayExecApprovalSnapshot {
val id: String
data class Pending(
val summary: GatewayExecApprovalSummary,
) : GatewayExecApprovalSnapshot {
override val id: String = summary.id
}
data class Terminal(
override val id: String,
val status: GatewayApprovalTerminalStatus,
val decision: String?,
) : GatewayExecApprovalSnapshot
}
internal data class GatewayExecApprovalResolution(
val applied: Boolean,
val approval: GatewayExecApprovalSnapshot.Terminal,
val attribution: GatewayExecApprovalResolutionAttribution =
if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse,
)
internal enum class GatewayExecApprovalResolutionAttribution {
AppliedHere,
PriorResponse,
Unknown,
}
private val execApprovalNoticePublications = AtomicLong()
data class GatewayExecApprovalNotice(
val approvalId: String,
val message: String,
val warning: Boolean,
// Distinct per constructed notice: a re-requested approval can lose again with an
// identical id/message, and the dismiss compareAndSet must not treat the stale
// banner as equal to its replacement.
val publication: Long = execApprovalNoticePublications.incrementAndGet(),
)
internal fun gatewayExecApprovalResolutionNotice(
resolution: GatewayExecApprovalResolution,
): GatewayExecApprovalNotice =
when (resolution.approval.status) {
GatewayApprovalTerminalStatus.Allowed -> {
val saved = resolution.approval.decision == "allow-always"
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalAllowedMessage(attribution = resolution.attribution, saved = saved),
warning = false,
)
}
GatewayApprovalTerminalStatus.Denied ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalDeniedMessage(resolution.attribution),
warning = true,
)
GatewayApprovalTerminalStatus.Expired ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
GatewayApprovalTerminalStatus.Cancelled ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
}
private fun gatewayExecApprovalAllowedMessage(
attribution: GatewayExecApprovalResolutionAttribution,
saved: Boolean,
): String {
if (attribution == GatewayExecApprovalResolutionAttribution.AppliedHere) {
if (saved) return "Approval allowed and saved."
return "Approval allowed once."
}
if (attribution == GatewayExecApprovalResolutionAttribution.PriorResponse) {
if (saved) return "A prior response already allowed this command and saved the choice."
return "A prior response already allowed this command once."
}
if (saved) return "Gateway recorded approval and saved the choice."
return "Gateway recorded approval once."
}
private fun gatewayExecApprovalDeniedMessage(attribution: GatewayExecApprovalResolutionAttribution): String =
when (attribution) {
GatewayExecApprovalResolutionAttribution.AppliedHere -> "Approval denied."
GatewayExecApprovalResolutionAttribution.PriorResponse -> "A prior response already denied this approval."
GatewayExecApprovalResolutionAttribution.Unknown -> "Gateway recorded a denial."
}
private fun gatewayExecApprovalTerminalMessage(status: GatewayApprovalTerminalStatus): String =
when (status) {
GatewayApprovalTerminalStatus.Expired -> "This approval expired before it could be resolved."
GatewayApprovalTerminalStatus.Cancelled -> "This approval was cancelled before it could be resolved."
else -> error("approval is not expired or cancelled")
}
internal fun gatewayExecApprovalRemoteTerminalNotice(
approval: GatewayExecApprovalSnapshot.Terminal,
): GatewayExecApprovalNotice =
gatewayExecApprovalResolutionNotice(
GatewayExecApprovalResolution(applied = false, approval = approval),
)
internal fun gatewayExecApprovalPriorResolutionNotice(id: String): GatewayExecApprovalNotice =
GatewayExecApprovalNotice(
approvalId = id,
message = gatewayExecApprovalPriorResolutionMessage(),
warning = true,
)
private fun gatewayExecApprovalPriorResolutionMessage(): String = "A prior response already resolved this approval."
internal fun normalizeGatewayExecApprovalDecision(value: String): String? =
when (value) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
/** Parses the terminal winner from an authenticated Gateway resolution event. */
internal fun parseGatewayExecApprovalResolvedEventTerminal(
payloadJson: String,
json: Json,
): GatewayExecApprovalSnapshot.Terminal? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = root.strictApprovalId("id") ?: return null
val decision = root.strictString("decision")?.let(::normalizeGatewayExecApprovalDecision) ?: return null
legacyGatewayExecApprovalTerminal(id, decision)
} catch (_: Throwable) {
null
}
internal enum class GatewayApprovalRpcFamily {
Canonical,
Legacy,
Unavailable,
}
/**
* Selects one read/write family for the lifetime of a Gateway hello catalog.
* Legacy exec.approval.* serves shipped Gateway v4 peers until the minimum supported
* Gateway advertises approval.get/approval.resolve.
*/
internal fun selectGatewayApprovalRpcFamily(methods: Set<String>): GatewayApprovalRpcFamily {
val hasCanonicalGet = "approval.get" in methods
val hasCanonicalResolve = "approval.resolve" in methods
if (hasCanonicalGet && hasCanonicalResolve) return GatewayApprovalRpcFamily.Canonical
if (
!hasCanonicalGet &&
!hasCanonicalResolve &&
"exec.approval.get" in methods &&
"exec.approval.resolve" in methods
) {
return GatewayApprovalRpcFamily.Legacy
}
return GatewayApprovalRpcFamily.Unavailable
}
internal fun buildGatewayExecApprovalGetParams(id: String): JsonObject = buildJsonObject { put("id", id) }
internal fun buildGatewayExecApprovalResolveParams(
id: String,
decision: String,
): JsonObject =
buildJsonObject {
put("id", id)
put("kind", "exec")
put("decision", decision)
}
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj.strictApprovalId("id") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
// The legacy list is discovery-only. Its embedded request can contain runtime-only
// details, so rendering waits for the reviewer-safe unified approval projection.
return GatewayExecApprovalSummary(
id = id,
commandText = nativeText("Command request"),
commandPreview = null,
warningText = null,
allowedDecisions = emptyList(),
host = null,
nodeId = null,
agentId = null,
createdAtMs = createdAtMs,
expiresAtMs = expiresAtMs,
)
}
internal fun gatewayExecApprovalTextForDisplay(text: String): String =
when (text) {
"Approval allowed and saved." -> nativeString("Approval allowed and saved.")
"Approval allowed once." -> nativeString("Approval allowed once.")
"A prior response already allowed this command and saved the choice." ->
nativeString("A prior response already allowed this command and saved the choice.")
"A prior response already allowed this command once." ->
nativeString("A prior response already allowed this command once.")
"Gateway recorded approval and saved the choice." ->
nativeString("Gateway recorded approval and saved the choice.")
"Gateway recorded approval once." -> nativeString("Gateway recorded approval once.")
"Approval denied." -> nativeString("Approval denied.")
"A prior response already denied this approval." ->
nativeString("A prior response already denied this approval.")
"Gateway recorded a denial." -> nativeString("Gateway recorded a denial.")
"This approval expired before it could be resolved." ->
nativeString("This approval expired before it could be resolved.")
"This approval was cancelled before it could be resolved." ->
nativeString("This approval was cancelled before it could be resolved.")
"A prior response already resolved this approval." ->
nativeString("A prior response already resolved this approval.")
"Command request" -> nativeString("Command request")
"Resolution outcome unknown. Actions stay disabled until the Gateway record is verified." ->
nativeString("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.")
"The Gateway still shows this approval as pending. Review it before trying again." ->
nativeString("The Gateway still shows this approval as pending. Review it before trying again.")
"Could not load approval details. Refresh and try again." ->
nativeString("Could not load approval details. Refresh and try again.")
"Could not load approvals." -> nativeString("Could not load approvals.")
"Could not resolve approval. Refresh and try again." ->
nativeString("Could not resolve approval. Refresh and try again.")
else -> text
}
internal fun parseGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
): GatewayExecApprovalSnapshot? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_GET_RESULT_KEYS)) return null
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
?.takeIf { it.id == expectedId }
} catch (_: Throwable) {
null
}
internal fun parseGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
expectedId: String,
expectedDecision: String,
): GatewayExecApprovalResolution? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_RESOLVE_RESULT_KEYS)) return null
val applied = root.strictBoolean("applied") ?: return null
val approval =
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
as? GatewayExecApprovalSnapshot.Terminal
?: return null
if (approval.id != expectedId) return null
// `applied=true` claims this write won. A different returned decision is an
// ambiguous write outcome, never evidence that the attempted approval applied.
if (applied && approval.decision != expectedDecision) return null
GatewayExecApprovalResolution(applied = applied, approval = approval)
} catch (_: Throwable) {
null
}
/** Parses the shipped pre-unified exec reviewer projection for old Gateway v4 peers. */
internal fun parseLegacyGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
createdAtMs: Long?,
): GatewayExecApprovalSnapshot.Pending? =
try {
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = obj.strictApprovalId("id") ?: return null
if (id != expectedId) return null
val normalizedCreatedAtMs = createdAtMs?.takeIf { it >= 0 } ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val commandText = obj.strictNonEmptyString("commandText") ?: return null
val commandPreview = obj.optionalString("commandPreview") ?: return null
val host = obj.optionalString("host") ?: return null
val nodeId = obj.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = obj.optionalString("agentId", requireNonEmpty = true) ?: return null
val allowedDecisions = parseAllowedDecisions(obj["allowedDecisions"] as? JsonArray) ?: return null
GatewayExecApprovalSnapshot.Pending(
GatewayExecApprovalSummary(
id = id,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = null,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = normalizedCreatedAtMs,
expiresAtMs = expiresAtMs,
),
)
} catch (_: Throwable) {
null
}
internal fun parseLegacyGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
): Boolean =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return false
root.strictBoolean("ok") == true
} catch (_: Throwable) {
false
}
internal fun legacyGatewayExecApprovalTerminal(
id: String,
decision: String,
): GatewayExecApprovalSnapshot.Terminal? {
val status =
when (decision) {
"allow-once", "allow-always" -> GatewayApprovalTerminalStatus.Allowed
"deny" -> GatewayApprovalTerminalStatus.Denied
else -> return null
}
return GatewayExecApprovalSnapshot.Terminal(id, status, decision)
}
private fun parseGatewayExecApprovalSnapshot(obj: JsonObject): GatewayExecApprovalSnapshot? {
val status = obj.strictString("status") ?: return null
val expectedKeys = APPROVAL_SNAPSHOT_KEYS_BY_STATUS[status] ?: return null
if (!obj.hasExactKeys(expectedKeys)) return null
val id = obj.strictApprovalId("id") ?: return null
obj.strictNonEmptyString("urlPath") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val presentation = obj["presentation"].asObjectOrNull() ?: return null
val summary = parseGatewayExecApprovalPresentation(id, createdAtMs, expiresAtMs, presentation) ?: return null
return when (status) {
"pending" -> GatewayExecApprovalSnapshot.Pending(summary)
"allowed" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Allowed,
expectedDecision = setOf("allow-once", "allow-always"),
)?.takeIf { terminal ->
terminal.decision?.let(summary.allowedDecisions::contains) == true
}
"denied" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Denied,
expectedDecision = setOf("deny"),
)
"expired" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Expired,
expectedDecision = null,
)
"cancelled" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Cancelled,
expectedDecision = null,
)
else -> null
}
}
private fun parseGatewayExecApprovalPresentation(
id: String,
createdAtMs: Long,
expiresAtMs: Long,
presentation: JsonObject,
): GatewayExecApprovalSummary? {
if (!presentation.hasOnlyKeys(EXEC_APPROVAL_PRESENTATION_KEYS)) return null
if (!presentation.keys.containsAll(EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS)) return null
// A unified lookup can return other approval owners. Android's exec inbox must
// never reinterpret plugin copy or metadata as an executable command request.
if (presentation.strictString("kind") != "exec") return null
val commandText = presentation.strictNonEmptyString("commandText") ?: return null
val allowedDecisions = parseAllowedDecisions(presentation["allowedDecisions"] as? JsonArray) ?: return null
val commandPreview = presentation.optionalString("commandPreview") ?: return null
val warningText = presentation.optionalString("warningText") ?: return null
val host = presentation.optionalString("host") ?: return null
val nodeId = presentation.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = presentation.optionalString("agentId", requireNonEmpty = true) ?: return null
return GatewayExecApprovalSummary(
id = id,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = warningText.value,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = createdAtMs,
expiresAtMs = expiresAtMs,
)
}
private fun parseTerminalApproval(
obj: JsonObject,
id: String,
status: GatewayApprovalTerminalStatus,
expectedDecision: Set<String>?,
): GatewayExecApprovalSnapshot.Terminal? {
obj.strictNonNegativeLong("resolvedAtMs") ?: return null
val reason = obj.strictString("reason") ?: return null
if (reason !in APPROVAL_TERMINAL_REASONS) return null
val decision = obj.strictString("decision")
if (expectedDecision == null) {
if (obj.containsKey("decision")) return null
} else if (decision !in expectedDecision) {
return null
}
return GatewayExecApprovalSnapshot.Terminal(id = id, status = status, decision = decision)
}
private fun parseAllowedDecisions(items: JsonArray?): List<String>? {
if (items == null || items.size !in 1..3) return null
val decisions = items.map { item -> item.strictString() ?: return null }
if (decisions.distinct().size != decisions.size || "deny" !in decisions) return null
return decisions.takeIf { values -> values.all { it in APPROVAL_DECISIONS } }
}
private data class OptionalString(
val value: String?,
)
private fun JsonObject.optionalString(
key: String,
requireNonEmpty: Boolean = false,
): OptionalString? {
val value = this[key]
if (value == null || value is JsonNull) return OptionalString(null)
val string = value.strictString() ?: return null
if (requireNonEmpty && string.isEmpty()) return null
return OptionalString(string)
}
private fun JsonObject.strictString(key: String): String? = this[key].strictString()
private fun JsonElement?.strictString(): String? =
(this as? JsonPrimitive)
?.takeIf { it.isString }
?.content
private fun JsonObject.strictNonEmptyString(key: String): String? =
strictString(key)
?.takeIf { it.isNotEmpty() }
private fun JsonObject.strictApprovalId(key: String): String? =
strictString(key)
?.takeIf(::isWellFormedGatewayApprovalId)
private fun JsonObject.strictBoolean(key: String): Boolean? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.booleanOrNull
private fun JsonObject.strictNonNegativeLong(key: String): Long? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.longOrNull
?.takeIf { it >= 0 }
// Closed-schema contract: the gateway protocol declares approval results with
// additionalProperties:false, so additive protocol changes hard-fail old clients by design.
private fun JsonObject.hasExactKeys(expected: Set<String>): Boolean = keys == expected
private fun JsonObject.hasOnlyKeys(allowed: Set<String>): Boolean = keys.all(allowed::contains)
internal fun isWellFormedGatewayApprovalId(value: String): Boolean {
if (value.isEmpty() || value == "." || value == "..") return false
var index = 0
while (index < value.length) {
val current = value[index]
when {
Character.isHighSurrogate(current) -> {
if (index + 1 >= value.length || !Character.isLowSurrogate(value[index + 1])) return false
index += 2
}
Character.isLowSurrogate(current) -> return false
else -> index += 1
}
}
return true
}
private val APPROVAL_GET_RESULT_KEYS = setOf("approval")
private val APPROVAL_RESOLVE_RESULT_KEYS = setOf("applied", "approval")
private val APPROVAL_SNAPSHOT_COMMON_KEYS =
setOf("id", "urlPath", "status", "createdAtMs", "expiresAtMs", "presentation")
private val APPROVAL_SNAPSHOT_KEYS_BY_STATUS =
mapOf(
"pending" to APPROVAL_SNAPSHOT_COMMON_KEYS,
"allowed" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"denied" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"expired" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
"cancelled" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
)
private val EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS = setOf("kind", "commandText", "allowedDecisions")
private val EXEC_APPROVAL_PRESENTATION_KEYS =
EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS +
setOf("commandPreview", "warningText", "host", "nodeId", "agentId")
private val APPROVAL_DECISIONS = setOf("allow-once", "allow-always", "deny")
private val APPROVAL_TERMINAL_REASONS =
setOf(
"user",
"timeout",
"malformed-verdict",
"no-route",
"run-aborted",
"gateway-restart",
"storage-corrupt",
)

View file

@ -0,0 +1,237 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
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
data class GatewayTalkSetupReadiness(
val realtimeTalk: GatewayTalkSetupState,
val dictation: GatewayTalkSetupState,
) {
companion object {
fun unverified(
issue: GatewayTalkSetupIssue = GatewayTalkSetupIssue.CatalogNotLoaded,
): GatewayTalkSetupReadiness =
GatewayTalkSetupReadiness(
realtimeTalk = GatewayTalkSetupState.Unverified(issue),
dictation = GatewayTalkSetupState.Unverified(issue),
)
}
}
sealed interface GatewayTalkSetupState {
data class Ready(
val provider: GatewayTalkProvider,
) : GatewayTalkSetupState
data class NeedsSetup(
val issue: GatewayTalkSetupIssue,
val provider: GatewayTalkProvider? = null,
) : GatewayTalkSetupState
/** Catalog failures must not disable a startup path that the Gateway still validates. */
data class Unverified(
val issue: GatewayTalkSetupIssue,
) : GatewayTalkSetupState
}
enum class GatewayTalkSetupTarget(
val title: NativeText,
) {
REALTIME_TALK(nativeText("Realtime Talk")),
DICTATION(nativeText("Dictation")),
}
sealed interface GatewayTalkSetupIssue {
data object CatalogNotLoaded : GatewayTalkSetupIssue
data object CatalogLoadFailed : GatewayTalkSetupIssue
data class GroupMissing(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class NoProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnknownProvider(
val target: GatewayTalkSetupTarget,
val providerId: String,
) : GatewayTalkSetupIssue
data class MissingReadiness(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class MissingActiveProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnsupportedProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureSelectedProvider(
val providerLabel: String,
) : GatewayTalkSetupIssue
}
data class GatewayTalkProvider(
val id: String,
val label: String,
)
val GatewayTalkSetupState.isReady: Boolean
get() = this is GatewayTalkSetupState.Ready
val GatewayTalkSetupState.requiresSetup: Boolean
get() = this is GatewayTalkSetupState.NeedsSetup
internal fun isAndroidRealtimeRelayModelSupported(model: String?): Boolean {
val normalized = model?.trim()?.lowercase() ?: return true
// extensions/openai/realtime-quicksilver.ts makes gpt-live WebRTC-only;
// the Gateway relay rejects it instead of providing a usable Android session.
return normalized != "gpt-live" && !normalized.startsWith("gpt-live-")
}
fun gatewayTalkSetupStatusText(state: GatewayTalkSetupState): String =
when (state) {
is GatewayTalkSetupState.Ready -> nativeString("Ready")
is GatewayTalkSetupState.NeedsSetup -> nativeString("Needs setup")
is GatewayTalkSetupState.Unverified -> nativeString("Unverified")
}
fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String = gatewayTalkSetupDescriptionText(state).resolveNativeText()
internal fun gatewayTalkSetupDescriptionText(state: GatewayTalkSetupState): NativeText =
when (state) {
is GatewayTalkSetupState.Ready ->
nativeText("\${state.provider.label} via Gateway relay", verbatimText(state.provider.label))
is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescriptionText(state.issue)
is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescriptionText(state.issue)
}
internal fun gatewayTalkSetupIssueDescriptionText(issue: GatewayTalkSetupIssue): NativeText =
when (issue) {
GatewayTalkSetupIssue.CatalogNotLoaded -> nativeText("Gateway talk catalog not loaded")
GatewayTalkSetupIssue.CatalogLoadFailed -> nativeText("Could not load Gateway talk catalog")
is GatewayTalkSetupIssue.GroupMissing ->
nativeText("Gateway did not return \${issue.target.title} setup", issue.target.title)
is GatewayTalkSetupIssue.NoProvider ->
nativeText("No \${issue.target.title} provider is configured on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.UnknownProvider ->
nativeText("Gateway selected unknown provider \${issue.providerId}", verbatimText(issue.providerId))
is GatewayTalkSetupIssue.MissingReadiness ->
nativeText("Gateway did not return \${issue.target.title} readiness", issue.target.title)
is GatewayTalkSetupIssue.ConfigureProvider ->
nativeText("Configure a \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.MissingActiveProvider ->
nativeText("Gateway did not identify the active \${issue.target.title} provider", issue.target.title)
is GatewayTalkSetupIssue.UnsupportedProvider ->
nativeText("Choose a supported \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.ConfigureSelectedProvider ->
nativeText("Configure \${issue.providerLabel} on the Gateway", verbatimText(issue.providerLabel))
}
internal fun parseGatewayTalkSetupReadiness(catalog: JsonObject?): GatewayTalkSetupReadiness {
if (catalog == null) return GatewayTalkSetupReadiness.unverified()
return GatewayTalkSetupReadiness(
realtimeTalk =
parseTalkCatalogGroup(catalog = catalog, key = "realtime", target = GatewayTalkSetupTarget.REALTIME_TALK),
dictation =
parseTalkCatalogGroup(catalog = catalog, key = "transcription", target = GatewayTalkSetupTarget.DICTATION),
)
}
private fun parseTalkCatalogGroup(
catalog: JsonObject,
key: String,
target: GatewayTalkSetupTarget,
): GatewayTalkSetupState {
val group =
catalog[key].asObjectOrNull()
?: return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.GroupMissing(target))
val providers =
(group["providers"] as? JsonArray)
?.mapNotNull(::parseTalkCatalogProvider)
.orEmpty()
val ready = (group["ready"] as? JsonPrimitive)?.booleanOrNull
val activeProviderId = group["activeProvider"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)
if (providers.isEmpty()) {
return when {
ready == false -> GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.NoProvider(target))
activeProviderId != null ->
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
else -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
if (activeProviderId == null) {
if (ready == false) {
return GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.ConfigureProvider(target))
}
// Older Gateways can omit the selected provider and report alias-backed rows as unconfigured
// even though session startup resolves them. Only an explicit readiness result is authoritative.
return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingActiveProvider(target))
}
val selected =
// Match Gateway registry precedence: canonical ids win before alias fallback.
providers.firstOrNull { it.matchesId(activeProviderId) }
?: providers.firstOrNull { it.matchesAlias(activeProviderId) }
?: return if (ready == false) {
GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.UnsupportedProvider(target))
} else {
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
}
val provider = GatewayTalkProvider(id = selected.id, label = selected.label)
return when (ready) {
true -> GatewayTalkSetupState.Ready(provider)
false ->
GatewayTalkSetupState.NeedsSetup(
issue = GatewayTalkSetupIssue.ConfigureSelectedProvider(selected.label),
provider = provider,
)
null -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
private data class TalkCatalogProvider(
val id: String,
val label: String,
val configured: Boolean,
val aliases: List<String>,
) {
fun matchesId(candidate: String): Boolean = id.equals(candidate, ignoreCase = true)
fun matchesAlias(candidate: String): Boolean = aliases.any { it.equals(candidate, ignoreCase = true) }
}
private fun parseTalkCatalogProvider(item: JsonElement): TalkCatalogProvider? {
val value = item.asObjectOrNull() ?: return null
val id = value["id"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null
val label = value["label"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: id
val aliases =
(value["aliases"] as? JsonArray)
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) }
.orEmpty()
return TalkCatalogProvider(
id = id,
label = label,
configured = (value["configured"] as? JsonPrimitive)?.booleanOrNull == true,
aliases = aliases,
)
}

View file

@ -0,0 +1,34 @@
package ai.openclaw.app
/**
* Persisted location capture mode advertised to the gateway.
*/
enum class LocationMode(
val rawValue: String,
) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
/** Parses persisted location mode text. */
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}
/** Resolves the in-app mode after Android's external background-location settings return. */
internal fun locationModeAfterBackgroundSettings(
previousMode: LocationMode,
foregroundGranted: Boolean,
backgroundGranted: Boolean,
): LocationMode =
when {
foregroundGranted && backgroundGranted -> LocationMode.Always
!foregroundGranted -> LocationMode.Off
previousMode == LocationMode.Always -> LocationMode.WhileUsing
else -> previousMode
}

View file

@ -0,0 +1,382 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
*/
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private val permissionRequester: PermissionRequester
get() = (application as NodeApp).permissionRequester
private var initializedViewModel: MainViewModel? = null
private var didStartViewModelCollectors = false
private var foreground = false
private val pendingIntentRouter = MainActivityPendingIntentRouter()
private val runtimeUiStarter = MainActivityRuntimeUiStarter()
private var screenshotScene: AndroidScreenshotScene? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingIntentRouter.setInitialIntent(intent)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester.attach(this)
if (BuildConfig.DEBUG) {
screenshotScene = parseAndroidScreenshotModeIntent(intent)
if (screenshotScene != null) hideScreenshotModeStatusBar()
}
setContent {
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
withContext(Dispatchers.Default) {
(application as NodeApp).prefs
}
val readyViewModel = viewModel
screenshotScene?.let(readyViewModel::enterScreenshotFixtureMode)
activateViewModel(readyViewModel)
activeViewModel = readyViewModel
}
val currentViewModel = activeViewModel
if (currentViewModel == null) {
OpenClawTheme {
StartupSurface()
}
} else {
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
OpenClawTheme(themeMode = appearanceThemeMode) {
RootScreen(viewModel = currentViewModel)
}
}
}
}
private fun hideScreenshotModeStatusBar() {
WindowCompat
.getInsetsController(window, window.decorView)
.apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.statusBars())
}
}
override fun onStart() {
super.onStart()
foreground = true
initializedViewModel?.setForeground(true)
}
override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) {
super.onTopResumedActivityChanged(isTopResumedActivity)
// minSdk 31 guarantees this callback and lets multi-resume select the actually interactive task.
updateTopResumedPermissionHost(
isTopResumedActivity = isTopResumedActivity,
activate = { permissionRequester.activate(this) },
deactivate = { permissionRequester.deactivate(this) },
refreshPermissionSurface = { initializedViewModel?.refreshNodePermissionSurface() },
)
}
override fun onStop() {
// Top-resumed ownership normally clears first; this also covers abnormal lifecycle ordering.
permissionRequester.deactivate(this)
foreground = false
if (shouldNotifyRuntimeBackgrounded(isChangingConfigurations)) {
initializedViewModel?.setForeground(false)
}
super.onStop()
}
override fun onDestroy() {
permissionRequester.detach(this)
super.onDestroy()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
val accepted =
pendingIntentRouter.onNewIntent(intent) { routedIntent ->
initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) }
}
if (!accepted) return
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
// AppCompatActivity marks this callback @CallSuper; it preserves Fragment and ActivityResult dispatch.
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults)
initializedViewModel?.refreshNodePermissionSurface()
}
/**
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
*/
private fun activateViewModel(readyViewModel: MainViewModel) {
if (initializedViewModel != null) return
initializedViewModel = readyViewModel
readyViewModel.setForeground(foreground)
startViewModelCollectors(readyViewModel)
if (!readyViewModel.claimInitialIntentRouting()) {
pendingIntentRouter.discardInitialIntent()
}
pendingIntentRouter.activate { initialIntent ->
handleLaunchIntent(viewModel = readyViewModel, intent = initialIntent)
}
readyViewModel.reportShareLaunchOverflow(pendingIntentRouter.takeShareOverflowCount())
}
/**
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
*/
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
if (didStartViewModelCollectors) return
didStartViewModelCollectors = true
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.preventSleep.collect { enabled ->
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.runtimeInitialized.collect { ready ->
runtimeUiStarter.onRuntimeInitialized(
ready = ready,
startRuntimeUi = screenshotScene == null,
attachRuntimeUi = {
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
},
startNodeService = {
NodeForegroundService.start(this@MainActivity)
},
)
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.shareLaunchOverflowRevision.collect { revision ->
if (revision == 0L) return@collect
repeat(readyViewModel.takeShareLaunchOverflowCount()) {
Toast
.makeText(
this@MainActivity,
nativeString("Too many shares are waiting to be added."),
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
/**
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
*/
private fun handleLaunchIntent(
viewModel: MainViewModel,
intent: Intent?,
) {
if (intent?.isShareLaunchIntent() == true) {
viewModel.handleShareLaunchIntent(intent)
return
}
parseHomeDestinationIntent(intent)?.let { destination ->
viewModel.requestHomeDestination(destination)
return
}
val request = parseAssistantLaunchIntent(intent) ?: return
viewModel.handleAssistantLaunch(request)
}
}
/** Queues shares until ViewModel activation while retaining only the latest ordinary launch intent. */
internal class MainActivityPendingIntentRouter {
private data class PendingLaunchIntent(
val sequence: Long,
val intent: Intent,
val initial: Boolean,
)
private var activated = false
private var sequence = 0L
private val pendingShareIntents = ArrayDeque<PendingLaunchIntent>()
private var pendingNonShareIntent: PendingLaunchIntent? = null
private var shareOverflowCount = 0
fun setInitialIntent(intent: Intent?) {
if (!activated && intent != null) store(intent = intent, initial = true)
}
fun onNewIntent(
intent: Intent,
routeIntent: (Intent) -> Unit,
): Boolean {
if (activated) {
routeIntent(intent)
return true
}
return store(intent = intent, initial = false)
}
fun discardInitialIntent() {
if (activated) return
pendingShareIntents.removeAll { it.initial }
if (pendingNonShareIntent?.initial == true) pendingNonShareIntent = null
}
fun activate(routeIntent: (Intent) -> Unit): Boolean {
if (activated) return false
activated = true
(pendingShareIntents + listOfNotNull(pendingNonShareIntent))
.sortedBy(PendingLaunchIntent::sequence)
.forEach { pending -> routeIntent(pending.intent) }
pendingShareIntents.clear()
pendingNonShareIntent = null
return true
}
fun takeShareOverflowCount(): Int =
shareOverflowCount.also {
shareOverflowCount = 0
}
private fun store(
intent: Intent,
initial: Boolean,
): Boolean {
val pending = PendingLaunchIntent(sequence = sequence++, intent = intent, initial = initial)
if (!intent.isShareLaunchIntent()) {
pendingNonShareIntent = pending
return true
}
if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) {
shareOverflowCount += 1
return false
}
pendingShareIntents.addLast(pending)
return true
}
}
private fun Intent.isShareLaunchIntent(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
/** Keeps launch intents one-shot across same-process Activity recreation, but not process death. */
internal class MainActivityInitialIntentGate {
private var claimed = false
fun claim(): Boolean {
if (claimed) return false
claimed = true
return true
}
}
internal fun shouldNotifyRuntimeBackgrounded(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations
internal fun updateTopResumedPermissionHost(
isTopResumedActivity: Boolean,
activate: () -> Unit,
deactivate: () -> Unit,
refreshPermissionSurface: () -> Unit,
) {
if (isTopResumedActivity) {
activate()
refreshPermissionSurface()
} else {
deactivate()
}
}
/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */
internal class MainActivityRuntimeUiStarter {
private var completed = false
fun onRuntimeInitialized(
ready: Boolean,
startRuntimeUi: Boolean,
attachRuntimeUi: () -> Unit,
startNodeService: () -> Unit,
) {
if (!ready || completed) return
if (!startRuntimeUi) {
completed = true
return
}
attachRuntimeUi()
completed = true
startNodeService()
}
}
@Composable
private fun StartupSurface() {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Black,
contentColor = Color.White,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "OPENCLAW",
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
)
}
}
}

View file

@ -0,0 +1,22 @@
package ai.openclaw.app
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/** Reuses the existing app task when a system surface brings OpenClaw forward. */
internal fun mainActivityPendingIntent(
context: Context,
requestCode: Int,
): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,123 @@
package ai.openclaw.app
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 ai.openclaw.app.wear.WearRealtimeChannelRegistry
import android.app.Application
import android.content.res.Configuration
import android.os.StrictMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/**
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
*/
class NodeApp : Application() {
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
// System share senders can create overlapping Activity tasks; keep one bounded process queue.
internal val chatShareDraftSeq = AtomicLong()
internal val chatShareDraftQueue = ChatShareDraftQueue()
internal val permissionRequester by lazy { PermissionRequester(this) }
private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val runtimeLock = Any()
private var runtimeInstance: NodeRuntime? = null
internal val wearProxyBridge: WearProxyBridge by lazy {
WearProxyBridge(
scope = runtimeScope,
sender = GoogleWearMessageSender(this),
peerResolver = GoogleWearPeerResolver(this),
handleRequest = { sourceNodeId, request ->
ensureBackgroundRuntime().handleWearProxyRequest(sourceNodeId, request)
},
)
}
internal val wearRealtimeChannels: WearRealtimeChannelRegistry by lazy {
WearRealtimeChannelRegistry(this, runtimeScope)
}
/**
* Returns the single NodeRuntime for this process, creating it on first use.
*/
fun ensureRuntime(): NodeRuntime =
synchronized(runtimeLock) {
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
}
/** Creates a cold-process runtime with foreground-only capabilities disabled before publication. */
internal fun ensureBackgroundRuntime(): NodeRuntime =
synchronized(runtimeLock) {
runtimeInstance
?: NodeRuntime(this, prefs, initialForeground = false).also { runtimeInstance = it }
}
internal fun ensureScreenshotFixtureRuntime(): NodeRuntime =
synchronized(runtimeLock) {
check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" }
runtimeInstance?.also { runtime ->
check(runtime.mode == NodeRuntimeMode.ScreenshotFixture) {
"NodeRuntime already started in live mode"
}
} ?: NodeRuntime(this, prefs, NodeRuntimeMode.ScreenshotFixture).also { runtimeInstance = it }
}
/**
* Reads the runtime without forcing startup, used by lifecycle probes and services.
*/
fun peekRuntime(): NodeRuntime? = synchronized(runtimeLock) { runtimeInstance }
/** Disconnects the current or concurrently constructing runtime without blocking the caller. */
internal fun disconnectRuntimeAsync() {
// The process-owned scope outlives a stopping service, so cancellation cannot
// strand an Activity-created runtime that the service has not observed yet.
runtimeScope.launch { peekRuntime()?.disconnect() }
}
/** Clears pairing auth without racing lazy process-runtime construction. */
suspend fun resetGatewaySetupAuth(stableId: String): Boolean {
val runtime =
synchronized(runtimeLock) {
runtimeInstance
?: NodeRuntime.forGatewayAuthReset(this, prefs).also { runtimeInstance = it }
}
return runtime.resetGatewaySetupAuth(stableId)
}
override fun onCreate() {
super.onCreate()
NativeStringResources.install(this)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// The process runtime survives Activity recreation, so retained text and
// serialized Home Canvas state need an explicit locale refresh signal.
NativeStringResources.setConfigurationLocales(newConfig)
notifyNativeLocaleChanged()
}
}

View file

@ -0,0 +1,469 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeLocaleChanges
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var runtimeRestoreJob: Job? = null
private var activeRuntime: NodeRuntime? = null
private var latestStartId = 0
private var voiceCaptureMode = VoiceCaptureMode.Off
@Volatile private var disconnectRequested = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial =
buildNotification(
title = nativeString("OpenClaw Node"),
text = nativeString("Starting…"),
)
startForegroundWithTypes(notification = initial)
}
private fun startRuntimeIfNeeded(startId: Int) {
if (activeRuntime != null || runtimeRestoreJob?.isActive == true || disconnectRequested) return
val app = application as NodeApp
runtimeRestoreJob =
scope.launch(Dispatchers.Default) {
try {
restoreStickyRuntime(
createRuntime = app::ensureBackgroundRuntime,
disconnectRequested = { disconnectRequested },
disconnectRuntime = NodeRuntime::disconnect,
) { restoredRuntime ->
withContext(Dispatchers.Main) {
runtimeRestoreJob = null
if (disconnectRequested) {
false
} else {
activeRuntime = restoredRuntime
observeRuntime(restoredRuntime)
true
}
}
}
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.e("OpenClawNodeService", "Failed to restore node runtime", err)
withContext(Dispatchers.Main) {
runtimeRestoreJob = null
if (!disconnectRequested && !stopSelfResult(startId)) {
startRuntimeIfNeeded(latestStartId)
}
}
}
}
}
private fun observeRuntime(runtime: NodeRuntime) {
// Keep the connection tuple atomic, then split connection and capture work so notification text
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
val notificationStates =
combine(
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}
refreshNotificationOnLocaleChanges(
states = notificationStates,
localeChanges = nativeLocaleChanges,
).collect { update ->
ensureChannelForLocaleRevision(update.localeRevision)
val state = update.state
voiceCaptureMode = state.mode
val title =
when {
state.connected && state.mode == VoiceCaptureMode.TalkMode ->
nativeString("OpenClaw Node · Talk")
state.connected -> nativeString("OpenClaw Node · Connected")
else -> nativeString("OpenClaw Node")
}
val displayStatus = gatewayConnectionStatusForDisplay(state.status)
val text =
(state.server?.let { nativeString("\$status · \$server", displayStatus, it) } ?: displayStatus) +
voiceNotificationSuffix(
mode = state.mode,
manualMicEnabled = state.capture.micEnabled,
manualMicListening = state.capture.micListening,
talkListening = state.capture.talkListening,
talkSpeaking = state.capture.talkSpeaking,
)
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
)
}
}
}
private var channelLocaleRevision: Long? = null
private fun ensureChannelForLocaleRevision(localeRevision: Long) {
if (channelLocaleRevision == localeRevision) return
ensureChannel()
channelLocaleRevision = localeRevision
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
latestStartId = maxOf(latestStartId, startId)
when (intent?.action) {
ACTION_STOP -> {
startSuppressed.set(true)
disconnectRequested = true
runtimeRestoreJob?.cancel()
runtimeRestoreJob = null
notificationJob?.cancel()
notificationJob = null
activeRuntime?.disconnect()
activeRuntime = null
(application as NodeApp).disconnectRuntimeAsync()
stopSelfResult(startId)
return START_NOT_STICKY
}
ACTION_RESUME -> {
startSuppressed.set(false)
disconnectRequested = false
}
ACTION_SET_VOICE_CAPTURE_MODE -> {
voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode()
startForegroundWithTypes(
notification =
buildNotification(
title = nativeString("OpenClaw Node"),
text =
if (voiceCaptureMode == VoiceCaptureMode.TalkMode) {
nativeString("Talk mode active")
} else {
nativeString("Connected")
},
),
)
}
}
if (disconnectRequested || startSuppressed.get()) {
// A STOP can lose stopSelfResult to a newer queued start. Let the newest
// start id close the service instead of leaving a disconnected FGS alive.
stopSelfResult(startId)
return START_NOT_STICKY
}
// START_STICKY recreates the service in a fresh process and calls this with a null intent.
startRuntimeIfNeeded(startId)
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
return START_STICKY
}
override fun onDestroy() {
notificationJob?.cancel()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
CHANNEL_ID,
nativeString("Connection"),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = nativeString("OpenClaw node connection status")
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(
title: String,
text: String,
): Notification {
val launchPending = mainActivityPendingIntent(this, requestCode = 1)
val visibleText = text + backgroundLocationNotificationSuffix(isBackgroundLocationActive())
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val stopPending =
PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(visibleText)
.setContentIntent(launchPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(0, nativeString("Disconnect"), stopPending)
.build()
}
private fun startForegroundWithTypes(notification: Notification) {
val serviceTypes =
foregroundServiceTypes(
voiceMode = voiceCaptureMode,
backgroundLocationActive = isBackgroundLocationActive(),
)
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
}
private fun isBackgroundLocationActive(): Boolean {
if (!SensitiveFeatureConfig.backgroundLocationEnabled) return false
if ((application as NodeApp).prefs.locationMode.value != LocationMode.Always) return false
val fineGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
return (fineGranted || coarseGranted) && backgroundGranted
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
private const val ACTION_RESUME = "ai.openclaw.app.action.RESUME"
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
private val startSuppressed = AtomicBoolean(false)
fun start(context: Context) {
if (startSuppressed.get()) return
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
startSuppressed.set(true)
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
internal fun resume(
context: Context,
startNow: Boolean,
) {
startSuppressed.set(false)
if (!startNow) return
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_RESUME)
context.startForegroundService(intent)
}
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
) {
if (startSuppressed.get()) return
val intent =
Intent(context, NodeForegroundService::class.java)
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
if (mode == VoiceCaptureMode.TalkMode) {
// Microphone foreground service type must be declared before Talk capture starts.
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
}
}
/** Restores process-local state after Android recreates a sticky service in a fresh process. */
internal suspend fun <T> restoreStickyRuntime(
createRuntime: () -> T,
disconnectRequested: () -> Boolean,
disconnectRuntime: (T) -> Unit,
activateRuntime: suspend (T) -> Boolean,
) {
// A queued recovery may begin after STOP; do not construct process state once
// disconnect has already won. The post-create check still closes the race during construction.
if (disconnectRequested()) return
val runtime = createRuntime()
var activated = false
try {
if (!disconnectRequested()) {
activated = activateRuntime(runtime)
}
} finally {
// Ownership transfers only after activation. Stop/cancellation during the
// dispatcher hop must disconnect the recovered runtime instead of leaking it.
if (!activated) {
disconnectRuntime(runtime)
}
}
}
internal fun foregroundServiceTypes(
voiceMode: VoiceCaptureMode,
backgroundLocationActive: Boolean,
): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
val voiceTypes =
when (voiceMode) {
VoiceCaptureMode.Off -> base
VoiceCaptureMode.ManualMic,
VoiceCaptureMode.TalkMode,
-> base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return if (backgroundLocationActive) {
voiceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
voiceTypes
}
}
internal fun backgroundLocationNotificationSuffix(active: Boolean): String =
if (active) {
nativeString(" · Location: Always")
} else {
""
}
internal fun voiceNotificationSuffix(
mode: VoiceCaptureMode,
manualMicEnabled: Boolean,
manualMicListening: Boolean,
talkListening: Boolean,
talkSpeaking: Boolean,
): String =
when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> nativeString(" · Talk: Speaking")
talkListening -> nativeString(" · Talk: Listening")
else -> nativeString(" · Talk: On")
}
VoiceCaptureMode.ManualMic ->
if (manualMicEnabled) {
if (manualMicListening) {
nativeString(" · Mic: Listening")
} else {
nativeString(" · Mic: Pending")
}
} else {
""
}
VoiceCaptureMode.Off -> ""
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
VoiceCaptureMode.entries.firstOrNull {
it.name == this
} ?: VoiceCaptureMode.Off
/** Connection fields that drive foreground notification title/body text. */
private data class VoiceNotificationBase(
val status: String,
val server: String?,
val connected: Boolean,
val mode: VoiceCaptureMode,
)
/** Voice capture fields that affect foreground-service type and suffix. */
private data class VoiceNotificationCapture(
val micEnabled: Boolean,
val micListening: Boolean,
val talkListening: Boolean,
val talkSpeaking: Boolean,
)
/** Aggregated notification state from runtime flows. */
private data class VoiceNotificationState(
val base: VoiceNotificationBase,
val capture: VoiceNotificationCapture,
) {
val status: String
get() = base.status
val server: String?
get() = base.server
val connected: Boolean
get() = base.connected
val mode: VoiceCaptureMode
get() = base.mode
}
/** Re-emits stable runtime state when app-owned notification copy changes locale. */
internal data class LocaleAwareNotificationState<T>(
val state: T,
val localeRevision: Long,
)
internal fun <T> refreshNotificationOnLocaleChanges(
states: Flow<T>,
localeChanges: Flow<Long>,
): Flow<LocaleAwareNotificationState<T>> =
combine(states, localeChanges) { state, localeRevision ->
LocaleAwareNotificationState(state = state, localeRevision = localeRevision)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
private val nativeChannelNotificationPackages =
setOf(
"com.discord",
"com.whatsapp",
"com.whatsapp.w4b",
"org.telegram.messenger",
"org.telegram.messenger.web",
"org.thunderdog.challegram",
"org.thoughtcrime.securesms",
)
/** Package-filter mode used before notification events are forwarded to the gateway. */
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
Allowlist("allowlist"),
Blocklist("blocklist"),
;
companion object {
/** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
/** Runtime policy used before forwarding notification events to a node session. */
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
val packages: Set<String>,
val quietHoursEnabled: Boolean,
val quietStart: String,
val quietEnd: String,
val maxEventsPerMinute: Int,
val sessionKey: String?,
val selfPackageName: String = "",
)
/** Applies the operator-configured package allow/block list after trimming input. */
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
return false
}
val self = selfPackageName.trim()
if (self.isNotEmpty() && normalized == self) {
return false
}
// Native channel sessions own these messages. Forwarding their notifications creates an
// unbound duplicate that can be answered from the wrong conversation.
if (normalized in nativeChannelNotificationPackages) {
return false
}
return when (mode) {
NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
}
}
/** Returns true for both same-day and overnight quiet-hour windows. */
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
): Boolean {
if (!quietHoursEnabled) {
return false
}
val startMinutes = parseLocalHourMinute(quietStart) ?: return false
val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
if (startMinutes == endMinutes) {
return true
}
val now =
Instant
.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
return if (startMinutes < endMinutes) {
nowMinutes in startMinutes until endMinutes
} else {
nowMinutes >= startMinutes || nowMinutes < endMinutes
}
}
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
/** Normalizes persisted or user-entered local times to strict HH:mm form. */
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
/** Converts strict local HH:mm text to minutes since midnight for window checks. */
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
val hour = parts[0].toInt()
val minute = parts[1].toInt()
return hour * 60 + minute
}
/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
/** Returns true when the current minute bucket still has forwarding capacity. */
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}
// Align all callers to the same minute bucket so concurrent notifications
// share the quota even when they arrive with slightly different timestamps.
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {
windowStartMs = currentWindow
eventsInWindow = 0
}
if (eventsInWindow >= maxEventsPerMinute) {
return false
}
eventsInWindow += 1
return true
}
}
}

View file

@ -0,0 +1,517 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.IdentityHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
/**
* Serializes Android runtime-permission prompts behind coroutine-friendly request calls.
*/
class PermissionRequester internal constructor(
context: Context,
private val requestCodeAllocator: PermissionRequestCodeAllocator = PermissionRequestCodeAllocator(),
) {
private data class ActivityHost(
val activity: ComponentActivity,
val permissionRequestLauncher: (Array<String>, Int) -> Unit,
)
private data class ActiveActivityHost(
val host: ActivityHost,
val activation: Long,
)
private data class PendingPermissionRequest(
val requestCode: Int,
val permissions: List<String>,
val deferred: CompletableDeferred<Map<String, Boolean>>,
)
private enum class RationaleResult {
Proceed,
Decline,
HostLost,
}
private enum class SettingsResult {
Shown,
HostLost,
}
private val appContext = context.applicationContext
private val mutex = Mutex()
private val activityHostLock = Any()
private val permissionRequestsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
private val activityHosts = IdentityHashMap<ComponentActivity, ActivityHost>()
private val activeActivitySequences = IdentityHashMap<ComponentActivity, Long>()
private val activeActivityHost = MutableStateFlow<ActiveActivityHost?>(null)
private var nextActivityActivation = 0L
private val pendingPermissionRequests = mutableMapOf<Int, PendingPermissionRequest>()
internal fun attach(
activity: ComponentActivity,
permissionRequestLauncher: (Array<String>, Int) -> Unit = { permissions, requestCode ->
ActivityCompat.requestPermissions(activity, permissions, requestCode)
},
) {
synchronized(activityHostLock) {
activityHosts[activity] = ActivityHost(activity, permissionRequestLauncher)
publishActiveActivityHostLocked()
}
}
internal fun activate(activity: ComponentActivity) {
synchronized(activityHostLock) {
check(activityHosts.containsKey(activity)) { "permission Activity must attach before activation" }
nextActivityActivation += 1
activeActivitySequences[activity] = nextActivityActivation
publishActiveActivityHostLocked()
}
}
internal fun deactivate(activity: ComponentActivity) {
synchronized(activityHostLock) {
activeActivitySequences.remove(activity)
publishActiveActivityHostLocked()
}
}
internal fun detach(activity: ComponentActivity) {
synchronized(activityHostLock) {
activeActivitySequences.remove(activity)
activityHosts.remove(activity)
publishActiveActivityHostLocked()
}
}
/**
* Request missing Android runtime permissions and return the final grant state for every requested permission.
*/
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(appContext, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
if (!confirmRationaleIfNeeded(missing, timeoutMs)) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = reservePermissionRequest(missing, deferred)
try {
launchPermissionRequest(missing, request.requestCode, timeoutMs)
} catch (err: Throwable) {
clearPermissionRequest(request)
throw err
}
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} finally {
// Timeout and caller cancellation both retire the request code before the mutex admits another prompt.
clearPermissionRequest(request)
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
showSettingsForPermanentDenials(merged, timeoutMs)
return merged
}
error("unreachable")
}
}
internal fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
val request =
synchronized(permissionRequestsLock) {
pendingPermissionRequests.remove(requestCode)
} ?: return false
val grants =
permissions
.mapIndexed { index, permission ->
permission to (grantResults.getOrNull(index) == PackageManager.PERMISSION_GRANTED)
}.toMap()
request.deferred.complete(request.permissions.associateWith { permission -> grants[permission] == true })
return true
}
private fun reservePermissionRequest(
permissions: List<String>,
deferred: CompletableDeferred<Map<String, Boolean>>,
): PendingPermissionRequest =
synchronized(permissionRequestsLock) {
val requestCode = requestCodeAllocator.allocate(pendingPermissionRequests::containsKey)
val request = PendingPermissionRequest(requestCode, permissions, deferred)
pendingPermissionRequests[requestCode] = request
request
}
private fun clearPermissionRequest(
request: PendingPermissionRequest,
) {
synchronized(permissionRequestsLock) {
if (pendingPermissionRequests[request.requestCode] === request) {
pendingPermissionRequests.remove(request.requestCode)
}
}
}
private fun publishActiveActivityHostLocked() {
val active =
activeActivitySequences.entries.maxByOrNull { it.value }?.let { entry ->
activityHosts[entry.key]?.let { host ->
ActiveActivityHost(host = host, activation = entry.value)
}
}
activeActivityHost.value = active
}
private suspend fun awaitActiveActivityHost(timeoutMs: Long): ActiveActivityHost =
withTimeout(timeoutMs) {
activeActivityHost
.filterNotNull()
.first { active ->
!active.host.activity.isFinishing && !active.host.activity.isDestroyed
}
}
private suspend fun launchPermissionRequest(
permissions: List<String>,
requestCode: Int,
timeoutMs: Long,
) {
withTimeout(timeoutMs) {
var rejected: ActiveActivityHost? = null
while (true) {
val active =
activeActivityHost
.filterNotNull()
.first { candidate ->
candidate != rejected &&
!candidate.host.activity.isFinishing &&
!candidate.host.activity.isDestroyed
}
val launched =
withContext(Dispatchers.Main) {
if (activeActivityHost.value != active) return@withContext false
val host = active.host
if (host.activity.isFinishing || host.activity.isDestroyed) return@withContext false
host.permissionRequestLauncher(permissions.toTypedArray(), requestCode)
true
}
if (launched) return@withTimeout
rejected = active
}
}
}
private suspend fun confirmRationaleIfNeeded(
permissions: List<String>,
timeoutMs: Long,
): Boolean =
withTimeout(timeoutMs) {
while (true) {
val active = awaitActiveActivityHost(timeoutMs)
val needsRationale =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) return@withContext null
permissions.any { permission ->
ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission)
}
} ?: continue
if (!needsRationale) return@withTimeout true
when (showRationaleDialog(active, permissions)) {
RationaleResult.Proceed -> return@withTimeout true
RationaleResult.Decline -> return@withTimeout false
RationaleResult.HostLost -> Unit
}
}
error("unreachable")
}
private suspend fun showSettingsForPermanentDenials(
grants: Map<String, Boolean>,
timeoutMs: Long,
) {
if (grants.values.none { granted -> !granted }) return
withTimeout(timeoutMs) {
while (true) {
val active = awaitActiveActivityHost(timeoutMs)
val denied =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) return@withContext null
grants
.filterValues { granted -> !granted }
.keys
.filter { permission ->
!ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission)
}
} ?: continue
if (denied.isEmpty()) return@withTimeout
when (showSettingsDialog(active, denied)) {
SettingsResult.Shown -> return@withTimeout
SettingsResult.HostLost -> Unit
}
}
}
}
private fun isCurrentActiveHost(active: ActiveActivityHost): Boolean =
activeActivityHost.value == active &&
!active.host.activity.isFinishing &&
!active.host.activity.isDestroyed
private suspend fun showRationaleDialog(
active: ActiveActivityHost,
permissions: List<String>,
): RationaleResult =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) {
return@withContext RationaleResult.HostLost
}
val activity = active.host.activity
suspendCancellableCoroutine { cont ->
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
var hostLossJob: Job? = null
val finished = AtomicBoolean(false)
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: RationaleResult?) {
if (!finished.compareAndSet(false, true)) return
hostLossJob?.cancel()
hostLossJob = null
removeObserver()
dialog?.dismiss()
if (result != null) {
cont.resume(result)
}
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
finish(RationaleResult.HostLost)
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
hostLossJob =
CoroutineScope(cont.context)
.launch(start = CoroutineStart.LAZY) {
activeActivityHost.first { current -> current != active }
finish(RationaleResult.HostLost)
}.also(Job::start)
cont.invokeOnCancellation {
mainHandler.post {
finish(null)
}
}
if (finished.get()) return@suspendCancellableCoroutine
dialog =
AlertDialog
.Builder(activity)
.setTitle(nativeString("Permission required"))
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton(nativeString("Continue")) { _, _ -> finish(RationaleResult.Proceed) }
.setNegativeButton(nativeString("Not now")) { _, _ -> finish(RationaleResult.Decline) }
.setOnCancelListener { finish(RationaleResult.Decline) }
.show()
}
}
private suspend fun showSettingsDialog(
active: ActiveActivityHost,
permissions: List<String>,
): SettingsResult =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) {
return@withContext SettingsResult.HostLost
}
val activity = active.host.activity
suspendCancellableCoroutine { cont ->
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
var hostLossJob: Job? = null
val finished = AtomicBoolean(false)
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: SettingsResult?) {
if (!finished.compareAndSet(false, true)) return
hostLossJob?.cancel()
hostLossJob = null
removeObserver()
dialog?.dismiss()
if (result != null) {
cont.resume(result)
}
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
finish(SettingsResult.HostLost)
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
hostLossJob =
CoroutineScope(cont.context)
.launch(start = CoroutineStart.LAZY) {
activeActivityHost.first { current -> current != active }
finish(SettingsResult.HostLost)
}.also(Job::start)
cont.invokeOnCancellation {
mainHandler.post {
finish(null)
}
}
if (finished.get()) return@suspendCancellableCoroutine
dialog =
AlertDialog
.Builder(activity)
.setTitle(nativeString("Enable permission in Settings"))
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton(nativeString("Open Settings")) { _, _ ->
if (!isCurrentActiveHost(active)) {
finish(SettingsResult.HostLost)
return@setPositiveButton
}
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
finish(SettingsResult.Shown)
}.setNegativeButton(nativeString("Cancel")) { _, _ ->
finish(SettingsResult.Shown)
}.setOnCancelListener { finish(SettingsResult.Shown) }
.setOnDismissListener { finish(SettingsResult.Shown) }
.show()
}
}
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return nativeString(
"OpenClaw needs \${labels.joinToString(\", \")} permissions to continue.",
labels.joinToString(", "),
)
}
private fun buildSettingsMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return nativeString(
"Please enable \${labels.joinToString(\", \")} in Android Settings to continue.",
labels.joinToString(", "),
)
}
private fun permissionLabel(permission: String): String =
when (permission) {
Manifest.permission.CAMERA -> nativeString("Camera")
Manifest.permission.RECORD_AUDIO -> nativeString("Microphone")
Manifest.permission.SEND_SMS -> nativeString("Send SMS")
Manifest.permission.READ_SMS -> nativeString("Read SMS")
Manifest.permission.READ_CONTACTS -> nativeString("Read Contacts")
Manifest.permission.WRITE_CONTACTS -> nativeString("Write Contacts")
Manifest.permission.READ_CALENDAR -> nativeString("Read Calendar")
Manifest.permission.WRITE_CALENDAR -> nativeString("Write Calendar")
Manifest.permission.READ_CALL_LOG -> nativeString("Read Call Log")
Manifest.permission.ACTIVITY_RECOGNITION -> nativeString("Motion Activity")
Manifest.permission.READ_MEDIA_IMAGES -> nativeString("Photos")
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED -> nativeString("Photos")
Manifest.permission.READ_EXTERNAL_STORAGE -> nativeString("Photos")
else -> permission
}
}
internal class PermissionRequestCodeAllocator(
initialRequestCode: Int = FIRST_PERMISSION_REQUEST_CODE,
) {
private var nextRequestCode = initialRequestCode
init {
require(initialRequestCode in FIRST_PERMISSION_REQUEST_CODE..LAST_PERMISSION_REQUEST_CODE)
}
fun allocate(isInUse: (Int) -> Boolean): Int {
repeat(PERMISSION_REQUEST_CODE_COUNT) {
val requestCode = nextRequestCode
nextRequestCode =
if (requestCode == LAST_PERMISSION_REQUEST_CODE) {
FIRST_PERMISSION_REQUEST_CODE
} else {
requestCode + 1
}
if (!isInUse(requestCode)) return requestCode
}
error("permission request codes exhausted")
}
internal companion object {
// AndroidX ActivityResultRegistry reserves request codes >= 0x10000. Direct ActivityCompat
// requests stay in a disjoint 16-bit range and skip live codes when the counter wraps.
const val FIRST_PERMISSION_REQUEST_CODE = 0x4C00
const val LAST_PERMISSION_REQUEST_CODE = 0xFFFF
private const val PERMISSION_REQUEST_CODE_COUNT =
LAST_PERMISSION_REQUEST_CODE - FIRST_PERMISSION_REQUEST_CODE + 1
}
}

View file

@ -0,0 +1,23 @@
package ai.openclaw.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal fun photoReadPermissionsForRequest(): List<String> =
when {
Build.VERSION.SDK_INT >= 34 ->
listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
)
Build.VERSION.SDK_INT >= 33 -> listOf(Manifest.permission.READ_MEDIA_IMAGES)
else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
internal fun hasPhotoReadPermission(context: Context): Boolean =
photoReadPermissionsForRequest().any { permission ->
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}

View file

@ -0,0 +1,802 @@
@file:Suppress("DEPRECATION")
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayCustomHeaders
import ai.openclaw.app.gateway.GatewayRegistryStore
import ai.openclaw.app.gateway.GatewayStoreMigration
import ai.openclaw.app.voice.VoiceWakePreferences
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
@Serializable
data class GatewayCredentials(
val token: String? = null,
val bootstrapToken: String? = null,
val password: String? = null,
) {
internal fun normalized(): GatewayCredentials =
copy(
token = token?.trim()?.takeIf { it.isNotEmpty() },
bootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() },
password = password?.trim()?.takeIf { it.isNotEmpty() },
)
}
/**
* Reactive settings facade for Android node preferences and encrypted gateway credentials.
*/
class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
) {
companion object {
private const val displayNameKey = "node.displayName"
private const val locationModeKey = "location.enabledMode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
private const val defaultNotificationForwardingEnabled = false
private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
private const val notificationsForwardingQuietHoursEnabledKey =
"notifications.forwarding.quietHoursEnabled"
private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyPrefix = "notifications.forwarding.sessionKey"
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
private const val installedAppsDisclosureConsentVersionKey =
"device.apps.prominentDisclosure.consentVersion"
private const val currentInstalledAppsDisclosureConsentVersion = 1
private const val accessibilityControlEnabledKey = "mobileUi.accessibilityControl.enabled"
private const val cameraEnabledKey = "camera.enabled"
private const val preferredCameraFacingKey = "camera.preferredFacing"
private const val voiceMicEnabledKey = "voice.micEnabled"
private const val preferredAudioInputDeviceKey = "voice.preferredAudioInputDevice"
private const val voiceWakeEnabledKey = "voiceWake.enabled"
private const val voiceWakeWordsKey = "voiceWake.triggerWords"
private const val appearanceThemeModeKey = "appearance.themeMode"
private const val chatModelFavoritesKey = "chat.modelFavorites"
private const val chatModelRecentsKey = "chat.modelRecents"
private const val sessionCustomGroupsKey = "sessions.customGroups"
private const val maxChatModelRecents = 5
private const val gatewayCustomHeadersKeyPrefix = "gateway.customHeaders."
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val hadPlainPrefsBeforeInit = plainPrefs.all.isNotEmpty()
// Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences.
private val masterKey by lazy {
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
// Lazy so plain-preference reads never touch the encrypted store (Robolectric
// has no AndroidKeyStore); the one-time legacy migration runs before the first
// gateway-state read, which is the earliest the registry can be observed.
val gatewayRegistry: GatewayRegistryStore by lazy {
GatewayStoreMigration(this).run()
GatewayRegistryStore(this, ::handleActiveGatewayChanged)
}
private val _displayName =
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(loadCameraEnabled())
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode = MutableStateFlow(loadLocationMode())
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost =
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow<String> = _manualHost
private val _manualPort =
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _installedAppsSharingEnabled =
MutableStateFlow(loadInstalledAppsSharingEnabled())
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
private val _accessibilityControlEnabled =
MutableStateFlow(plainPrefs.getBoolean(accessibilityControlEnabledKey, false))
val accessibilityControlEnabled: StateFlow<Boolean> = _accessibilityControlEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
private val _notificationForwardingMode =
MutableStateFlow(
NotificationPackageFilterMode.fromRawValue(
plainPrefs.getString(notificationsForwardingModeKey, null),
),
)
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
val notificationForwardingPackages: StateFlow<Set<String>> = _notificationForwardingPackages
private val storedQuietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
private val storedQuietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
private val storedQuietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
private val _notificationForwardingQuietHoursEnabled =
MutableStateFlow(storedQuietHoursEnabled)
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> = _notificationForwardingQuietHoursEnabled
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
private val _notificationForwardingMaxEventsPerMinute =
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
private val _notificationForwardingSessionKey by lazy {
MutableStateFlow(loadNotificationForwardingSessionKey(gatewayRegistry.activeStableId.value))
}
val notificationForwardingSessionKey: StateFlow<String?> get() = _notificationForwardingSessionKey
private val _voiceMicEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceMicEnabledKey, false))
val voiceMicEnabled: StateFlow<Boolean> = _voiceMicEnabled
private val _voiceWakeEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceWakeEnabledKey, false))
val voiceWakeEnabled: StateFlow<Boolean> = _voiceWakeEnabled
private val _voiceWakeWords = MutableStateFlow(loadVoiceWakeWords())
val voiceWakeWords: StateFlow<List<String>> = _voiceWakeWords
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
private val _preferredCameraFacing =
MutableStateFlow(plainPrefs.getString(preferredCameraFacingKey, null).takeIf { it == "back" } ?: "front")
val preferredCameraFacing: StateFlow<String> = _preferredCameraFacing
private val _preferredAudioInputDevice =
MutableStateFlow(plainPrefs.getString(preferredAudioInputDeviceKey, null)?.takeIf(String::isNotBlank))
val preferredAudioInputDevice: StateFlow<String?> = _preferredAudioInputDevice
private val _appearanceThemeMode =
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
private val _modelFavorites = MutableStateFlow(loadChatModelRefs(chatModelFavoritesKey))
val modelFavorites: StateFlow<List<String>> = _modelFavorites
private val _modelRecents = MutableStateFlow(loadChatModelRefs(chatModelRecentsKey))
val modelRecents: StateFlow<List<String>> = _modelRecents
// Custom session group names the user created locally; assigned groups also
// persist server-side via the session category field (mirrors web localStorage).
private val _sessionCustomGroups = MutableStateFlow(loadChatModelRefs(sessionCustomGroupsKey))
val sessionCustomGroups: StateFlow<List<String>> = _sessionCustomGroups
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(cameraEnabledKey, value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
plainPrefs.edit { putString(locationModeKey, mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun grantInstalledAppsDisclosureConsent() {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, true)
putInt(installedAppsDisclosureConsentVersionKey, currentInstalledAppsDisclosureConsentVersion)
}
_installedAppsSharingEnabled.value = true
}
fun revokeInstalledAppsDisclosureConsent() {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, false)
remove(installedAppsDisclosureConsentVersionKey)
}
_installedAppsSharingEnabled.value = false
}
fun setAccessibilityControlEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(accessibilityControlEnabledKey, value) }
_accessibilityControlEnabled.value = value
}
private fun loadInstalledAppsSharingEnabled(): Boolean {
val enabled = plainPrefs.getBoolean(installedAppsSharingEnabledKey, false)
val consentVersion = plainPrefs.getInt(installedAppsDisclosureConsentVersionKey, 0)
if (enabled && consentVersion == currentInstalledAppsDisclosureConsentVersion) return true
// A shipped opt-in without this disclosure version cannot authorize package-inventory access.
// Canonicalize both keys so every later enable starts with fresh affirmative consent.
if (enabled || consentVersion != 0) {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, false)
remove(installedAppsDisclosureConsentVersionKey)
}
}
return false
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
// Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops.
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
val packages =
when (mode) {
NotificationPackageFilterMode.Allowlist -> configuredPackages
NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
}
val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
val quietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
val quietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
// NotificationListenerService owns a separate SecurePrefs facade, so resolve the persisted
// pointer per event rather than trusting that facade's process-local registry flow.
val sessionKey = loadNotificationForwardingSessionKey(gatewayRegistry.storedActiveStableId())
val quietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
return NotificationForwardingPolicy(
enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
mode = mode,
packages = packages,
quietHoursEnabled = quietHoursEnabled,
quietStart = quietStart,
quietEnd = quietEnd,
maxEventsPerMinute = maxEvents.coerceAtLeast(1),
sessionKey = sessionKey,
selfPackageName = normalizedAppPackage,
)
}
internal fun setNotificationForwardingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
_notificationForwardingEnabled.value = value
}
internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
_notificationForwardingMode.value = mode
}
internal fun setNotificationForwardingPackages(packages: List<String>) {
val sanitized =
packages
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
.toList()
.sorted()
// Persist deterministic JSON so settings diffs and state restoration are stable.
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
}
internal fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
if (!enabled) {
plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
_notificationForwardingQuietHoursEnabled.value = false
return true
}
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
plainPrefs.edit {
putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
putString(notificationsForwardingQuietStartKey, normalizedStart)
putString(notificationsForwardingQuietEndKey, normalizedEnd)
}
_notificationForwardingQuietHoursEnabled.value = enabled
_notificationForwardingQuietStart.value = normalizedStart
_notificationForwardingQuietEnd.value = normalizedEnd
return true
}
internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
val normalized = value.coerceAtLeast(1)
plainPrefs.edit {
putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
}
_notificationForwardingMaxEventsPerMinute.value = normalized
}
internal fun setNotificationForwardingSessionKey(value: String?) {
val stableId = gatewayRegistry.activeStableId.value ?: return
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit {
putString(notificationForwardingSessionKeyKey(stableId), normalized.orEmpty())
}
_notificationForwardingSessionKey.value = normalized
}
fun loadGatewayCredentials(stableId: String): GatewayCredentials {
// Credential reads are gateway state; force the lazy registry so the one-time
// legacy migration has run before the first per-gateway bundle is resolved.
gatewayRegistry
val raw = securePrefs.getString(gatewayCredentialsKey(stableId), null) ?: return GatewayCredentials()
return runCatching { json.decodeFromString<GatewayCredentials>(raw).normalized() }.getOrDefault(GatewayCredentials())
}
fun saveGatewayCredentials(
stableId: String,
credentials: GatewayCredentials,
) {
securePrefs.edit {
putString(gatewayCredentialsKey(stableId), json.encodeToString(credentials.normalized()))
}
}
fun saveGatewayCredentials(
stableId: String,
token: String? = null,
bootstrapToken: String? = null,
password: String? = null,
) {
saveGatewayCredentials(stableId, GatewayCredentials(token, bootstrapToken, password))
}
fun clearGatewayCredentials(stableId: String) {
securePrefs.edit { remove(gatewayCredentialsKey(stableId)) }
}
/**
* Custom proxy headers are per-gateway credentials (Cloudflare Access-style service tokens).
* They live in the encrypted store like the other gateway secrets and are read at connect
* time; never log their values.
*/
fun loadGatewayCustomHeaders(stableId: String): Map<String, String> {
val raw = securePrefs.getString(gatewayCustomHeadersKey(stableId), null) ?: return emptyMap()
val stored =
runCatching { json.decodeFromString<Map<String, String>>(raw) }.getOrElse { return emptyMap() }
return GatewayCustomHeaders.sanitized(stored)
}
fun saveGatewayCustomHeaders(
stableId: String,
headers: Map<String, String>,
) {
val key = gatewayCustomHeadersKey(stableId)
val sanitized = GatewayCustomHeaders.sanitized(headers)
if (sanitized.isEmpty()) {
securePrefs.edit { remove(key) }
return
}
securePrefs.edit { putString(key, json.encodeToString(sanitized)) }
}
/** Forgets one gateway's proxy credentials; forgetting a gateway is the removal boundary. */
fun clearGatewayCustomHeaders(stableId: String) {
securePrefs.edit { remove(gatewayCustomHeadersKey(stableId)) }
}
private fun gatewayCustomHeadersKey(stableId: String) = "$gatewayCustomHeadersKeyPrefix${stableId.trim()}"
/** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
/** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
) {
val key = "gateway.tls.$stableId"
plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun clearGatewayTlsFingerprint(stableId: String) {
plainPrefs.edit { remove("gateway.tls.$stableId") }
}
fun clearNotificationForwardingSessionKey(stableId: String) {
plainPrefs.edit { remove(notificationForwardingSessionKeyKey(stableId)) }
if (gatewayRegistry.activeStableId.value == stableId) {
_notificationForwardingSessionKey.value = null
}
}
fun getString(key: String): String? = securePrefs.getString(key, null)
fun putString(
key: String,
value: String,
) {
securePrefs.edit { putString(key, value) }
}
// KTX edit(commit = true) discards commit's Boolean; the identity migration fails closed on it.
@Suppress("UseKtx")
internal fun putStringSynchronously(
key: String,
value: String,
): Boolean = securePrefs.edit().putString(key, value).commit()
fun remove(key: String) {
securePrefs.edit { remove(key) }
}
internal fun containsSecureKey(key: String): Boolean = securePrefs.contains(key)
internal fun secureKeys(): Set<String> = securePrefs.all.keys
internal fun removeSecureKeys(keys: List<String>) {
securePrefs.edit { keys.forEach { remove(it) } }
}
internal fun moveSecureString(
sourceKey: String,
destinationKey: String?,
) {
val value = securePrefs.getString(sourceKey, null)
securePrefs.edit {
if (destinationKey != null && value != null) putString(destinationKey, value)
remove(sourceKey)
}
}
internal fun getPlainString(key: String): String? = plainPrefs.getString(key, null)
internal fun getPlainBoolean(
key: String,
defaultValue: Boolean,
): Boolean = plainPrefs.getBoolean(key, defaultValue)
internal fun getPlainInt(
key: String,
defaultValue: Int,
): Int = plainPrefs.getInt(key, defaultValue)
internal fun movePlainString(
sourceKey: String,
destinationKey: String?,
) {
val value = plainPrefs.getString(sourceKey, null)?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit(commit = true) {
if (destinationKey != null && value != null) putString(destinationKey, value)
remove(sourceKey)
}
}
private fun gatewayCredentialsKey(stableId: String): String {
val normalized = stableId.trim()
require(normalized.isNotEmpty()) { "Gateway stable id cannot be empty" }
return "gateway.credentials.$normalized"
}
private fun notificationForwardingSessionKeyKey(stableId: String): String = "$notificationsForwardingSessionKeyPrefix.$stableId"
private fun loadNotificationForwardingSessionKey(stableId: String?): String? =
stableId
?.let(::notificationForwardingSessionKeyKey)
?.let { plainPrefs.getString(it, null) }
?.trim()
?.takeIf { it.isNotEmpty() }
private fun handleActiveGatewayChanged(stableId: String?) {
_notificationForwardingSessionKey.value = loadNotificationForwardingSessionKey(stableId)
}
private fun createSecurePrefs(
context: Context,
name: String,
): SharedPreferences =
EncryptedSharedPreferences.create(
context,
name,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
// Instance id is not secret; it scopes local credentials and survives display-name changes.
val fresh = UUID.randomUUID().toString()
plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
// Replace the historical generic name with a device-specific default once.
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved
}
fun setVoiceMicEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceMicEnabledKey, value) }
_voiceMicEnabled.value = value
}
fun setVoiceWakeEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceWakeEnabledKey, value) }
_voiceWakeEnabled.value = value
}
fun setVoiceWakeWords(words: List<String>) {
val sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
plainPrefs.edit { putString(voiceWakeWordsKey, JsonArray(sanitized.map(::JsonPrimitive)).toString()) }
_voiceWakeWords.value = sanitized
}
fun setSpeakerEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
_speakerEnabled.value = value
}
fun setPreferredCameraFacing(value: String) {
val facing = value.takeIf { it == "back" } ?: "front"
plainPrefs.edit { putString(preferredCameraFacingKey, facing) }
_preferredCameraFacing.value = facing
}
fun setPreferredAudioInputDevice(value: String?) {
val key = value?.takeIf(String::isNotBlank)
plainPrefs.edit {
if (key == null) remove(preferredAudioInputDeviceKey) else putString(preferredAudioInputDeviceKey, key)
}
_preferredAudioInputDevice.value = key
}
private fun loadVoiceWakeWords(): List<String> {
val stored = plainPrefs.getString(voiceWakeWordsKey, null) ?: return VoiceWakePreferences.defaultTriggerWords
val decoded =
runCatching {
(json.parseToJsonElement(stored) as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content }
}.getOrNull()
return VoiceWakePreferences.sanitizeTriggerWords(decoded.orEmpty())
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
_appearanceThemeMode.value = mode
}
fun toggleModelFavorite(ref: String) {
val trimmed = ref.trim()
if (trimmed.isEmpty()) return
val next =
if (trimmed in _modelFavorites.value) {
_modelFavorites.value - trimmed
} else {
_modelFavorites.value + trimmed
}
persistChatModelRefs(chatModelFavoritesKey, next)
_modelFavorites.value = next
}
fun recordModelRecent(ref: String) {
val trimmed = ref.trim()
if (trimmed.isEmpty()) return
val next = (listOf(trimmed) + _modelRecents.value.filterNot { it == trimmed }).take(maxChatModelRecents)
persistChatModelRefs(chatModelRecentsKey, next)
_modelRecents.value = next
}
fun setSessionCustomGroups(groups: List<String>) {
val sanitized = groups.map(String::trim).filter { it.isNotEmpty() }.distinct()
persistChatModelRefs(sessionCustomGroupsKey, sanitized)
_sessionCustomGroups.value = sanitized
}
private fun persistChatModelRefs(
key: String,
refs: List<String>,
) {
val encoded = JsonArray(refs.map(::JsonPrimitive)).toString()
plainPrefs.edit { putString(key, encoded) }
}
private fun loadNotificationForwardingPackages(): Set<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {
return emptySet()
}
return try {
val element = json.parseToJsonElement(raw)
val array = element as? JsonArray ?: return emptySet()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}.toSet()
} catch (_: Throwable) {
emptySet()
}
}
private fun loadLocationMode(): LocationMode {
val raw = plainPrefs.getString(locationModeKey, "off")
val stored = LocationMode.fromRawValue(raw)
val resolved =
if (stored == LocationMode.Always && !SensitiveFeatureConfig.backgroundLocationEnabled) {
LocationMode.WhileUsing
} else {
stored
}
if (resolved != stored) {
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadCameraEnabled(): Boolean {
if (plainPrefs.contains(cameraEnabledKey)) {
return plainPrefs.getBoolean(cameraEnabledKey, false)
}
val migratedValue = hadPlainPrefsBeforeInit
plainPrefs.edit { putBoolean(cameraEnabledKey, migratedValue) }
return migratedValue
}
private fun loadChatModelRefs(key: String): List<String> {
val raw = plainPrefs.getString(key, null)?.trim()
if (raw.isNullOrEmpty()) return emptyList()
return try {
val array = json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}.distinct()
} catch (_: Throwable) {
emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package ai.openclaw.app
/** Normalizes blank gateway session keys to the legacy main session alias. */
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
return trimmed
.removePrefix("agent:")
.substringBefore(':')
.trim()
.ifEmpty { null }
}
/** Builds the node session key shape consumed by gateway chat and presence APIs. */
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,
): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}
/** Human-readable, device-unique label applied when Android creates or adopts its session. */
internal fun buildAndroidAppSessionLabel(
displayName: String?,
deviceId: String,
): String {
val deviceSuffix = deviceId.take(12)
val displaySuffix = displayName?.trim()?.takeUtf16Safe(96)?.takeIf { it.isNotEmpty() }
return listOfNotNull("OpenClaw App", displaySuffix, deviceSuffix).joinToString(" · ")
}

View file

@ -0,0 +1,238 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
private const val CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED = "clawhub_risk_acknowledgement_required"
internal const val CLAWHUB_INSTALL_REQUEST_TIMEOUT_MS = 125_000L
internal const val CLAWHUB_SKILL_GATEWAY_UNAVAILABLE = "Update the Gateway to search and install ClawHub skills from Android."
internal val CLAWHUB_SKILL_GATEWAY_METHODS = setOf("skills.search", "skills.detail", "skills.install")
data class GatewayClawHubSkillSearchState(
val query: String = "",
val searching: Boolean = false,
val results: List<GatewayClawHubSkillSummary> = emptyList(),
val reviewingSlug: String? = null,
val installReview: GatewayClawHubInstallReview? = null,
val installingSlugs: Set<String> = emptySet(),
val acknowledgeSlug: String? = null,
val acknowledgeVersion: String? = null,
val errorText: String? = null,
val messageText: String? = null,
)
data class GatewayClawHubSkillSummary(
val slug: String,
val displayName: String,
val summary: String?,
val version: String?,
)
data class GatewayClawHubInstallReview(
val slug: String,
val displayName: String,
val summary: String?,
val version: String,
val author: String,
)
internal data class GatewayClawHubInstallRejection(
val message: String,
val warning: String?,
val acknowledgeVersion: String?,
val requiresAcknowledgement: Boolean,
)
internal fun parseClawHubSearchResults(
raw: String,
json: Json,
): List<GatewayClawHubSkillSummary> {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return emptyList()
return (root["results"] as? JsonArray)
?.mapNotNull { item ->
val value = item as? JsonObject ?: return@mapNotNull null
val slug = value.string("slug") ?: return@mapNotNull null
val displayName = value.string("displayName") ?: return@mapNotNull null
GatewayClawHubSkillSummary(
slug = slug,
displayName = displayName,
summary = value.string("summary"),
version = value.string("version"),
)
}.orEmpty()
}
internal fun parseClawHubInstallReview(
raw: String,
fallback: GatewayClawHubSkillSummary,
json: Json,
): GatewayClawHubInstallReview? {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return null
val skill = root["skill"] as? JsonObject
val latestVersion = root["latestVersion"] as? JsonObject
val owner = root["owner"] as? JsonObject
// The detail response is the install review boundary. Prefer its current
// version over the potentially stale search result shown before review.
val version = latestVersion?.string("version") ?: fallback.version ?: return null
val ownerDisplayName = owner?.string("displayName")
val ownerHandle = owner?.string("handle")
val reviewedSlug =
canonicalClawHubSkillReference(
slug = skill?.string("slug") ?: fallback.slug,
ownerHandle = ownerHandle,
) ?: return null
val author =
when {
ownerDisplayName != null && ownerHandle != null && !ownerDisplayName.equals(ownerHandle, ignoreCase = true) ->
"$ownerDisplayName (@$ownerHandle)"
ownerDisplayName != null -> ownerDisplayName
ownerHandle != null -> "@$ownerHandle"
else -> "Unknown publisher"
}
return GatewayClawHubInstallReview(
slug = reviewedSlug,
displayName = skill?.string("displayName") ?: fallback.displayName,
summary = skill?.string("summary") ?: fallback.summary,
version = version,
author = author,
)
}
internal fun clawHubInstallRejection(
error: GatewaySession.ErrorShape,
attemptedVersion: String?,
): GatewayClawHubInstallRejection {
val details = error.details
val reviewedVersion = attemptedVersion?.trim()?.takeIf(String::isNotEmpty)
val gatewayVersion = details?.clawhubVersion?.trim()?.takeIf(String::isNotEmpty)
val acknowledgementRequested =
details?.clawhubTrustCode == CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED
val requiresAcknowledgement =
acknowledgementRequested && reviewedVersion != null && gatewayVersion == reviewedVersion
return GatewayClawHubInstallRejection(
message =
if (acknowledgementRequested && !requiresAcknowledgement) {
"The Gateway evaluated a different ClawHub release. Review the skill again before installing."
} else {
error.message.ifBlank { "The Gateway rejected this ClawHub install." }
},
warning = details?.clawhubWarning?.trim()?.takeIf(String::isNotEmpty),
acknowledgeVersion = reviewedVersion.takeIf { requiresAcknowledgement },
requiresAcknowledgement = requiresAcknowledgement,
)
}
internal fun supportsClawHubSkillManagement(methods: Set<String>): Boolean = methods.containsAll(CLAWHUB_SKILL_GATEWAY_METHODS)
internal fun clawHubSearchParams(query: String): String =
buildJsonObject {
query.trim().takeIf(String::isNotEmpty)?.let { put("query", JsonPrimitive(it)) }
put("limit", JsonPrimitive(25))
}.toString()
internal fun clawHubDetailParams(slug: String): String = buildJsonObject { put("slug", JsonPrimitive(slug)) }.toString()
internal fun clawHubInstallParams(
slug: String,
version: String?,
acknowledgeRisk: Boolean,
): String =
buildJsonObject {
put("source", JsonPrimitive("clawhub"))
put("slug", JsonPrimitive(slug))
version?.trim()?.takeIf(String::isNotEmpty)?.let { put("version", JsonPrimitive(it)) }
if (acknowledgeRisk) put("acknowledgeClawHubRisk", JsonPrimitive(true))
put("timeoutMs", JsonPrimitive(120_000))
}.toString()
internal fun skillEnabledParams(
skillKey: String,
enabled: Boolean,
): String =
buildJsonObject {
put("skillKey", JsonPrimitive(skillKey))
put("enabled", JsonPrimitive(enabled))
}.toString()
internal fun formatClawHubInstallMessage(
message: String,
warning: String?,
): String = if (warning.isNullOrBlank()) message else "$message\n\n$warning"
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return skills.any { it.matchesClawHubReference(reference) }
}
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
version: String,
): Boolean =
parseClawHubSkillReference(slug)?.let { reference ->
skills.any { it.matchesClawHubReference(reference) && it.clawHubInstalledVersion == version }
} ?: false
internal fun isClawHubSkillOperationActive(
activeSlugs: Set<String>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return activeSlugs.any { activeSlug ->
val active = parseClawHubSkillReference(activeSlug) ?: return@any false
active.slug.equals(reference.slug, ignoreCase = true) &&
(
active.ownerHandle == null ||
reference.ownerHandle == null ||
active.ownerHandle.equals(reference.ownerHandle, ignoreCase = true)
)
}
}
private data class ClawHubSkillReference(
val slug: String,
val ownerHandle: String?,
)
private fun parseClawHubSkillReference(rawValue: String): ClawHubSkillReference? {
val value = rawValue.trim()
if (value.isEmpty()) return null
if (!value.startsWith("@")) return ClawHubSkillReference(value, null)
val parts = value.drop(1).split("/")
if (parts.size != 2 || parts.any(String::isEmpty)) return null
return ClawHubSkillReference(slug = parts[1], ownerHandle = parts[0].lowercase())
}
private fun canonicalClawHubSkillReference(
slug: String,
ownerHandle: String?,
): String? {
val reference = parseClawHubSkillReference(slug) ?: return null
val owner = ownerHandle?.trim()?.takeIf(String::isNotEmpty)?.lowercase() ?: reference.ownerHandle
return owner?.let { "@$it/${reference.slug}" } ?: reference.slug
}
private fun GatewaySkillSummary.matchesClawHubReference(reference: ClawHubSkillReference): Boolean {
if (!clawHubValid) return false
val installedReference = clawHubSlug?.let(::parseClawHubSkillReference) ?: return false
if (!installedReference.slug.equals(reference.slug, ignoreCase = true)) return false
val requestedOwner = reference.ownerHandle ?: return true
val installedOwner = installedReference.ownerHandle ?: clawHubOwnerHandle
return installedOwner?.equals(requestedOwner, ignoreCase = true) == true
}
internal fun clawHubInstallOutcomeUnknownMessage(slug: String): String = "The result for $slug is unknown. Reconnect, refresh Skills, then retry; the Gateway safely joins a matching install that is still running."
private fun JsonObject.string(key: String): String? =
(get(key) as? JsonPrimitive)
?.contentOrNull
?.trim()
?.takeIf(String::isNotEmpty)

View file

@ -0,0 +1,31 @@
package ai.openclaw.app
import android.icu.text.BreakIterator
import java.util.Locale
// BreakIterator creation is relatively expensive, and instances are not thread-safe.
private val graphemeBreakIterator = ThreadLocal.withInitial { BreakIterator.getCharacterInstance(Locale.ROOT) }
internal fun String.firstGraphemeOrNull(): String? {
if (isEmpty()) return null
val iterator = checkNotNull(graphemeBreakIterator.get())
iterator.setText(this)
iterator.first()
val end = iterator.next()
return if (end == BreakIterator.DONE) null else substring(0, end)
}
internal fun String.uppercaseFirstGraphemeOrNull(): String? {
val grapheme = firstGraphemeOrNull() ?: return null
val firstCodePoint = grapheme.codePointAt(0)
val uppercaseCodePoint = Character.toUpperCase(firstCodePoint)
// Keep badge width stable while preserving the rest of the grapheme cluster.
return String(Character.toChars(uppercaseCodePoint)) + grapheme.substring(Character.charCount(firstCodePoint))
}
internal fun String.takeUtf16Safe(maxChars: Int): String {
if (length <= maxChars) return this
// Keep the code-unit cap without leaving a high surrogate at its boundary.
val endsOnHighSurrogate = maxChars > 0 && Character.isHighSurrogate(this[maxChars - 1])
return take(if (endsOnHighSurrogate) maxChars - 1 else maxChars)
}

View file

@ -0,0 +1,10 @@
package ai.openclaw.app
/**
* Persisted voice capture mode that controls foreground-service microphone requirements.
*/
enum class VoiceCaptureMode {
Off,
ManualMic,
TalkMode,
}

View file

@ -0,0 +1,81 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
/** One entry from the read-only `agents.workspace.list` gateway RPC. */
data class GatewayWorkspaceEntry(
val path: String,
val name: String,
val isDirectory: Boolean,
val size: Long?,
val updatedAtMs: Long?,
)
/** One directory page of an agent workspace listing. */
data class GatewayWorkspaceListing(
val path: String,
val entries: List<GatewayWorkspaceEntry>,
val totalEntries: Int,
val offset: Int,
)
/** One previewable workspace file from `agents.workspace.get`. */
data class GatewayWorkspaceFile(
val path: String,
val name: String,
val size: Long,
val mimeType: String,
val isBase64: Boolean,
val content: String,
)
private fun JsonElement?.asLongOrNull(): Long? = (this as? JsonPrimitive)?.longOrNull
internal fun parseWorkspaceListing(root: JsonElement): GatewayWorkspaceListing? {
val obj = root.asObjectOrNull() ?: return null
val entries =
(obj["entries"] as? JsonArray)?.mapNotNull { item ->
val entry = item.asObjectOrNull() ?: return@mapNotNull null
// Paths/names are opaque workspace identifiers echoed back to the
// gateway; never trim them or entries with edge whitespace break.
val path = entry["path"].asStringOrNull().orEmpty()
val name = entry["name"].asStringOrNull().orEmpty()
if (path.isEmpty() || name.isEmpty()) return@mapNotNull null
GatewayWorkspaceEntry(
path = path,
name = name,
isDirectory = entry["kind"].asStringOrNull() == "directory",
size = entry["size"].asLongOrNull(),
updatedAtMs = entry["updatedAtMs"].asLongOrNull(),
)
} ?: emptyList()
return GatewayWorkspaceListing(
path = obj["path"].asStringOrNull().orEmpty(),
entries = entries,
totalEntries = obj["totalEntries"].asLongOrNull()?.toInt() ?: entries.size,
offset = obj["offset"].asLongOrNull()?.toInt() ?: 0,
)
}
internal fun parseWorkspaceFile(root: JsonElement): GatewayWorkspaceFile? {
val file = root.asObjectOrNull()?.get("file").asObjectOrNull() ?: return null
val path = file["path"].asStringOrNull().orEmpty()
if (path.isEmpty()) return null
return GatewayWorkspaceFile(
path = path,
name =
file["name"]
.asStringOrNull()
.orEmpty()
.ifEmpty { path.substringAfterLast('/') },
size = file["size"].asLongOrNull() ?: 0L,
mimeType = file["mimeType"].asStringOrNull().orEmpty().ifEmpty { "text/plain" },
isBase64 = file["encoding"].asStringOrNull() == "base64",
content = file["content"].asStringOrNull().orEmpty(),
)
}

View file

@ -0,0 +1,112 @@
package ai.openclaw.app.chat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.time.Instant
data class BackgroundTask(
val id: String,
val status: String,
val runtime: String,
val title: String?,
val agentId: String?,
val childSessionKey: String?,
val createdAtMs: Long?,
val updatedAtMs: Long?,
val startedAtMs: Long?,
val endedAtMs: Long?,
val progress: String?,
val terminal: String?,
val error: String?,
val prompt: String?,
) {
val isActive: Boolean
get() = status == "queued" || status == "running"
val displayTitle: String
get() = title?.trim()?.takeIf { it.isNotEmpty() } ?: id
val displayStatus: BackgroundTaskDisplayStatus
get() =
when (status) {
"queued" -> BackgroundTaskDisplayStatus.Queued
"running" -> BackgroundTaskDisplayStatus.Running
"completed" -> BackgroundTaskDisplayStatus.Completed
"failed", "cancelled", "timed_out" -> BackgroundTaskDisplayStatus.Failed
else -> BackgroundTaskDisplayStatus.Failed
}
val output: String?
get() {
val candidates =
if (status == "failed" || status == "timed_out") {
listOf(error, terminal, progress)
} else {
listOf(terminal, error, progress)
}
return candidates.firstOrNull { !it.isNullOrBlank() }
}
val activityAtMs: Long
get() = updatedAtMs ?: endedAtMs ?: startedAtMs ?: createdAtMs ?: 0L
}
enum class BackgroundTaskDisplayStatus {
Queued,
Running,
Completed,
Failed,
}
internal fun parseBackgroundTasks(
json: Json,
payload: String,
): List<BackgroundTask> {
val root = json.parseToJsonElement(payload).jsonObject
return root["tasks"]?.jsonArray?.mapNotNull(::parseBackgroundTask).orEmpty()
}
internal fun parseBackgroundTask(element: JsonElement): BackgroundTask? {
val objectValue = element as? JsonObject ?: return null
fun string(key: String): String? = objectValue[key]?.jsonPrimitive?.contentOrNull
val id = string("id")?.takeIf { it.isNotBlank() } ?: return null
return BackgroundTask(
id = id,
status = string("status") ?: "running",
runtime = string("runtime") ?: "background",
title = string("title"),
agentId = string("agentId"),
childSessionKey = string("childSessionKey"),
createdAtMs = objectValue["createdAt"]?.let(::parseTaskTimestampMs),
updatedAtMs = objectValue["updatedAt"]?.let(::parseTaskTimestampMs),
startedAtMs = objectValue["startedAt"]?.let(::parseTaskTimestampMs),
endedAtMs = objectValue["endedAt"]?.let(::parseTaskTimestampMs),
progress = string("progressSummary"),
terminal = string("terminalSummary"),
error = string("error"),
prompt = string("prompt"),
)
}
internal fun mergeBackgroundTasks(vararg groups: List<BackgroundTask>): List<BackgroundTask> =
groups
.flatMap { it }
.groupBy { it.id }
.mapValues { (_, snapshots) ->
snapshots.maxWith(compareBy<BackgroundTask> { it.activityAtMs }.thenBy { !it.isActive })
}.values
.sortedWith(compareByDescending<BackgroundTask> { it.isActive }.thenByDescending { it.activityAtMs })
private fun parseTaskTimestampMs(element: JsonElement): Long? {
val primitive = element.jsonPrimitive
primitive.doubleOrNull?.let { return it.toLong() }
return primitive.contentOrNull?.let { raw -> runCatching { Instant.parse(raw).toEpochMilli() }.getOrNull() }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
package ai.openclaw.app.chat
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
/** Identifies the gateway chat that owns transient composer state and async results. */
internal data class ChatComposerOwner(
val gatewayStableId: String?,
val agentId: String,
val sessionKey: String,
val routingVerified: Boolean = true,
)
/** Last routing owner proven for one gateway, retained while that gateway reconnects. */
internal data class GatewayDefaultAgentOwner(
val gatewayStableId: String,
val agentId: String,
)
internal fun resolveGatewayDefaultAgentId(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner?,
): String? =
gatewayDefaultAgentId?.trim()?.takeIf { it.isNotEmpty() }
?: lastVerifiedOwner
?.takeIf { it.gatewayStableId == gatewayStableId }
?.agentId
internal fun resolveChatComposerOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner? = null,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val explicitAgentId = resolveAgentIdFromMainSessionKey(effectiveSessionKey)
val effectiveDefaultAgentId = resolveGatewayDefaultAgentId(gatewayStableId, gatewayDefaultAgentId, lastVerifiedOwner)
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = explicitAgentId ?: effectiveDefaultAgentId ?: "main",
sessionKey = effectiveSessionKey,
routingVerified = explicitAgentId != null || effectiveDefaultAgentId != null,
)
}
/** Returns an owner only when routing can be proven from the session key or gateway hello. */
internal fun resolveChatComposerRoutingOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner? {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val agentId =
resolveAgentIdFromMainSessionKey(effectiveSessionKey)
?: gatewayDefaultAgentId?.trim()?.takeIf(String::isNotEmpty)
?: return null
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = agentId,
sessionKey = effectiveSessionKey,
routingVerified = true,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.SessionObserverDigest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.Locale
private val visibleChatMessageRoles = setOf("user", "assistant", "system", "custom")
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
/** Keeps transcript rows limited to roles Android renders as user-visible chat. */
internal fun normalizeVisibleChatMessageRole(role: String?): String? =
role
?.trim()
?.lowercase(Locale.US)
?.takeIf(visibleChatMessageRoles::contains)
/**
* Chat transcript item as delivered by gateway chat history and live chat events.
*/
data class ChatMessage(
val id: String,
val role: String,
val content: List<ChatMessageContent>,
val timestampMs: Long?,
val idempotencyKey: String? = null,
/** Canonical transcript-tree identity supplied by chat.history. */
val entryId: String? = null,
)
/** One selectable transcript branch returned by sessions.branches.list. */
data class SessionBranch(
val leafEntryId: String,
val headline: String,
val messageCount: Int,
val updatedAt: String?,
val active: Boolean,
)
data class SessionRewindResult(
val editorText: String?,
val editorAttachments: List<SessionEditorAttachment>,
)
data class SessionForkResult(
val sessionKey: String,
val editorText: String?,
val editorAttachments: List<SessionEditorAttachment>,
)
data class SessionEditorAttachment(
val mimeType: String,
val data: String,
)
data class ChatTranscriptAnchorState(
val sessionKey: String,
val newestItemId: String?,
val completedEndedAt: Long?,
val completedNewestItemId: String?,
)
/**
* One content part in a chat message; media carries either bounded base64 or a managed artifact reference.
*/
data class ChatMessageContent(
val type: String = "text",
val text: String? = null,
val mimeType: String? = null,
val fileName: String? = null,
val artifactId: String? = null,
val url: String? = null,
val openUrl: String? = null,
val alt: String? = null,
val width: Int? = null,
val height: Int? = null,
val sizeBytes: Long? = null,
val base64: String? = null,
val durationMs: Long? = null,
val playback: String? = null,
val widget: ChatWidgetPreview? = null,
)
data class ChatWidgetPreview(
val title: String?,
val path: String,
val preferredHeight: Int?,
val sandbox: String,
) {
val height: Int
get() = (preferredHeight ?: 320).coerceIn(160, 1200)
}
/**
* Tool call placeholder shown while a gateway run is still streaming.
*/
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long,
val isError: Boolean? = null,
)
enum class ChatPlanStepStatus {
Pending,
InProgress,
Completed,
}
data class ChatPlanStep(
val step: String,
val status: ChatPlanStepStatus,
)
/** Parses a complete gateway plan snapshot, including legacy string-only steps. */
internal fun parseChatPlanSteps(element: JsonElement?): List<ChatPlanStep> {
val entries = element as? JsonArray ?: return emptyList()
var hasInProgressStep = false
return entries.mapNotNull { entry ->
val parsed =
when (entry) {
is JsonObject -> {
val step =
(entry["step"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
val status =
when ((entry["status"] as? JsonPrimitive)?.takeIf { it.isString }?.content) {
"pending" -> ChatPlanStepStatus.Pending
"in_progress" -> ChatPlanStepStatus.InProgress
"completed" -> ChatPlanStepStatus.Completed
else -> return@mapNotNull null
}
ChatPlanStep(step = step, status = status)
}
is JsonPrimitive -> {
val step =
entry
.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
ChatPlanStep(step = step, status = ChatPlanStepStatus.Pending)
}
else -> return@mapNotNull null
}
if (parsed.status == ChatPlanStepStatus.InProgress) {
if (hasInProgressStep) return@mapNotNull null
hasInProgressStep = true
}
parsed
}
}
/** Gateway-advertised thinking choice for the active provider/model pair. */
data class ChatThinkingLevelOption(
val id: String,
val label: String,
)
/** Thinking choices currently shown by chat, including whether the Gateway supplied them. */
data class ChatThinkingLevelSelection(
val options: List<ChatThinkingLevelOption>,
val isGatewayProvided: Boolean,
)
internal val defaultChatThinkingLevelSelection =
ChatThinkingLevelSelection(
options =
listOf(
ChatThinkingLevelOption(id = "off", label = "Off"),
ChatThinkingLevelOption(id = "low", label = "Low"),
ChatThinkingLevelOption(id = "medium", label = "Medium"),
ChatThinkingLevelOption(id = "high", label = "High"),
),
isGatewayProvided = false,
)
internal data class ChatActiveRunPresentation(
val count: Int = 0,
val runId: String? = null,
val clockKey: String? = null,
val outputTokens: Long? = null,
)
/**
* Stable session selector row; [key] is the gateway session key used in chat requests.
*/
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val ownerAgentId: String? = null,
val displayName: String? = null,
val derivedTitle: String? = null,
val label: String? = null,
val category: String? = null,
val pinned: Boolean? = null,
val archived: Boolean? = null,
val unread: Boolean? = null,
val lastReadAt: Long? = null,
val agentStatus: ChatSessionAgentStatus? = null,
val hasAgentStatusMetadata: Boolean = agentStatus != null,
val observerDigest: SessionObserverDigest? = null,
val hasObserverDigestMetadata: Boolean = observerDigest != null,
val lastActivityAt: Long? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
val modelProvider: String? = null,
val model: String? = null,
val thinkingLevel: String? = null,
val thinkingLevels: List<ChatThinkingLevelOption>? = null,
val thinkingDefault: String? = null,
val contextTokens: Long? = null,
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
val hasActiveRun: Boolean? = null,
val activeRunIds: List<String>? = null,
val hasActiveRunMetadata: Boolean = hasActiveRun != null || activeRunIds != null,
val parentSessionKey: String? = null,
val spawnedBy: String? = null,
val hasActiveSubagentRun: Boolean? = null,
val subagentRunState: String? = null,
val swarmGroupId: String? = null,
val swarmPhase: String? = null,
val swarmPhaseRank: Int? = null,
val swarmLog: String? = null,
val status: String? = null,
val lastRunError: String? = null,
val startedAt: Long? = null,
val endedAt: Long? = null,
val runtimeMs: Long? = null,
val outputTokens: Long? = null,
val hasRunMetadata: Boolean =
status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null,
)
data class ChatSessionAgentStatus(
val note: String,
val expiresAt: Long,
val attention: String? = null,
)
/** Local fallback for server-side `sessions.list` search over cached entries. */
fun filterSessionEntries(
sessions: List<ChatSessionEntry>,
search: String,
): List<ChatSessionEntry> {
val query = search.trim().lowercase()
if (query.isEmpty()) return sessions
return sessions.filter { session ->
listOfNotNull(session.displayName, session.label, session.key)
.any { it.lowercase().contains(query) }
}
}
/**
* Slash command metadata exposed by the gateway for text-surface chat clients.
*/
data class ChatCommandEntry(
val name: String,
val description: String,
val category: String? = null,
val textAliases: List<String> = emptyList(),
val acceptsArgs: Boolean = false,
)
/**
* Run still streaming on the gateway when a chat.history snapshot was captured;
* [text] is the assistant text buffered so far (may be empty for runs without deltas).
*/
data class ChatInFlightRun(
val runId: String,
val text: String,
val plan: ChatPlanSnapshot? = null,
)
data class ChatPlanSnapshot(
val steps: List<ChatPlanStep>,
val explanation: String? = null,
)
/**
* Snapshot of one chat session, including optional thinking level selected on the gateway.
*/
data class ChatHistory(
val sessionKey: String,
val sessionId: String?,
val thinkingLevel: String?,
val messages: List<ChatMessage>,
val sessionInfo: ChatSessionEntry? = null,
val inFlightRun: ChatInFlightRun? = null,
)
/**
* User-selected attachment payload sent to the gateway as inline base64.
*/
data class OutgoingAttachment(
val type: String,
val mimeType: String,
val fileName: String,
val base64: String,
val durationMs: Long? = null,
)

View file

@ -0,0 +1,111 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.Question
import ai.openclaw.app.gateway.QuestionRecord
enum class ChatQuestionStatus {
Pending,
Submitting,
Answered,
AnsweredElsewhere,
Expired,
Cancelled,
Unavailable,
}
data class ChatQuestionPrompt(
val record: QuestionRecord,
val submitting: Boolean = false,
val skipping: Boolean = false,
val answeredLocally: Boolean = false,
val errorText: String? = null,
val terminalObservedAtMs: Long? = null,
val recoveryUnavailable: Boolean = false,
) {
fun status(nowMs: Long = System.currentTimeMillis()): ChatQuestionStatus =
if (recoveryUnavailable) {
ChatQuestionStatus.Unavailable
} else {
when (record.status) {
"answered" -> if (answeredLocally) ChatQuestionStatus.Answered else ChatQuestionStatus.AnsweredElsewhere
"cancelled" -> ChatQuestionStatus.Cancelled
"expired" -> ChatQuestionStatus.Expired
else ->
when {
nowMs >= record.expiresAtMs -> ChatQuestionStatus.Expired
submitting -> ChatQuestionStatus.Submitting
else -> ChatQuestionStatus.Pending
}
}
}
}
data class ChatQuestionDraft(
val selectedOptions: Map<String, Set<String>> = emptyMap(),
val otherText: Map<String, String> = emptyMap(),
) {
fun toggle(
question: Question,
label: String,
): ChatQuestionDraft {
if (question.options.none { it.label == label }) return this
val selected = selectedOptions[question.questionId].orEmpty()
val next =
if (question.multiSelect == true) {
if (label in selected) selected - label else selected + label
} else if (selected == setOf(label)) {
emptySet()
} else {
setOf(label)
}
return copy(
selectedOptions = selectedOptions + (question.questionId to next),
otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.questionId to "") else otherText,
)
}
fun setOther(
question: Question,
value: String,
): ChatQuestionDraft {
if (question.options.isNotEmpty() && question.isOther != true) return this
val clearOptions = question.multiSelect != true && value.isNotBlank()
return copy(
selectedOptions = if (clearOptions) selectedOptions + (question.questionId to emptySet()) else selectedOptions,
otherText = otherText + (question.questionId to value),
)
}
fun answers(questions: List<Question>): Map<String, List<String>>? {
val result = linkedMapOf<String, List<String>>()
for (question in questions) {
val selected = selectedOptions[question.questionId].orEmpty()
val values = question.options.mapNotNull { option -> option.label.takeIf { it in selected } }.toMutableList()
otherText[question.questionId]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add)
if (values.isEmpty()) return null
result[question.questionId] = values
}
return result
}
}
internal fun questionsForSession(
prompts: List<ChatQuestionPrompt>,
sessionKey: String,
mainSessionKey: String,
activeAgentId: String,
): List<ChatQuestionPrompt> {
val main = mainSessionKey.trim().ifEmpty { "main" }
val current = sessionKey.trim().let { if (it == "main") main else it }
val activeAgent = activeAgentId.trim().lowercase()
return prompts.filter { prompt ->
val key = prompt.record.sessionKey?.trim() ?: return@filter true
val sessionMatches = key == sessionKey || key == current || (key == "main" && current == main)
val promptAgent =
prompt.record.agentId
?.trim()
.orEmpty()
.lowercase()
sessionMatches && (promptAgent.isEmpty() || activeAgent.isEmpty() || promptAgent == activeAgent)
}
}

View file

@ -0,0 +1,295 @@
package ai.openclaw.app.chat
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private const val MAX_TRACKED_SWARM_GROUPS = 10_000
private const val MAX_TRACKED_SWARM_CHILDREN = 100_000
private const val MAX_SWARM_PAGE_REQUESTS = 100
private const val MAX_RENDERED_SWARM_DOTS_PER_PHASE = 256
enum class ChatSwarmDotStatus {
Running,
Queued,
Failed,
Done,
;
val label: String
get() = name
}
data class ChatSwarmDot(
val key: String,
val label: String,
val status: ChatSwarmDotStatus,
)
data class ChatSwarmPhase(
val key: String,
val title: String?,
val dots: List<ChatSwarmDot>,
val hidden: Int,
)
data class ChatSwarmGroup(
val groupId: String,
val label: String,
val running: Int,
val done: Int,
val failed: Int,
val narrator: String?,
val phases: List<ChatSwarmPhase>,
)
internal data class ChatSwarmSessionPage(
val sessions: List<ChatSessionEntry>,
val totalCount: Int?,
val nextOffset: Int?,
val hasMore: Boolean?,
)
internal suspend fun collectChatSwarmChildSessions(
fetchPage: suspend (Int) -> ChatSwarmSessionPage,
): List<ChatSessionEntry> {
val rowsByKey = linkedMapOf<String, ChatSessionEntry>()
var remainingPageRequests = MAX_SWARM_PAGE_REQUESTS
repeat(4) {
val rowsBeforePass = rowsByKey.size
var expectedTotal: Int? = null
val seenOffsets = mutableSetOf<Int>()
var offset = 0
while (
remainingPageRequests > 0 &&
rowsByKey.size < MAX_TRACKED_SWARM_CHILDREN &&
seenOffsets.add(offset)
) {
remainingPageRequests -= 1
val page = fetchPage(offset)
expectedTotal = page.totalCount
for (row in page.sessions) {
rowsByKey[row.key] = row
if (rowsByKey.size >= MAX_TRACKED_SWARM_CHILDREN) break
}
if (rowsByKey.size >= MAX_TRACKED_SWARM_CHILDREN) return rowsByKey.values.toList()
val hasMore = page.hasMore ?: expectedTotal?.let { offset + page.sessions.size < it } ?: false
val nextOffset = page.nextOffset ?: (offset + page.sessions.size)
if (!hasMore || page.sessions.isEmpty() || nextOffset <= offset) break
offset = nextOffset
}
val added = rowsByKey.size - rowsBeforePass
if (
remainingPageRequests == 0 ||
added == 0 ||
expectedTotal?.let { rowsByKey.size >= it } != false
) {
return rowsByKey.values.toList()
}
}
return rowsByKey.values.toList()
}
internal fun chatSwarmEventBelongsToParent(
payload: JsonObject,
matchesParent: (String) -> Boolean,
): Boolean {
val source = payload["session"].asObjectOrNull() ?: payload
val explicitParent =
normalizedSwarmValue(source["parentSessionKey"].asStringOrNull())
?: normalizedSwarmValue(source["spawnedBy"].asStringOrNull())
if (explicitParent != null) return matchesParent(explicitParent)
val kind = normalizedSwarmValue(payload["kind"].asStringOrNull())
if (kind == "phase" || kind == "log") {
val sessionKey = normalizedSwarmValue(payload["sessionKey"].asStringOrNull()) ?: return false
return matchesParent(sessionKey)
}
val groupId =
normalizedSwarmValue(payload["swarmGroupId"].asStringOrNull())
?: normalizedSwarmValue(source["swarmGroupId"].asStringOrNull())
?: return false
if (!groupId.startsWith("swarm:")) return false
val generatedParent = groupId.removePrefix("swarm:").substringBeforeLast(':', missingDelimiterValue = "")
return generatedParent.isNotEmpty() && matchesParent(generatedParent)
}
private fun normalizedSwarmValue(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
internal class ChatSwarmActivityTracker {
private val currentPhaseByGroup = linkedMapOf<String, String>()
private val phaseRankByGroupPhase = linkedMapOf<String, Int>()
private var nextPhaseRank = 0
private val latestLogByGroup = linkedMapOf<String, String>()
private val phaseByChild = linkedMapOf<String, String>()
fun clear() {
currentPhaseByGroup.clear()
phaseRankByGroupPhase.clear()
nextPhaseRank = 0
latestLogByGroup.clear()
phaseByChild.clear()
}
fun observe(payload: JsonObject): Boolean {
val source = payload["session"].asObjectOrNull() ?: payload
val groupId = normalized(payload["swarmGroupId"].asStringOrNull()) ?: normalized(source["swarmGroupId"].asStringOrNull()) ?: return false
val kind = normalized((if ("kind" in payload) payload["kind"] else source["kind"]).asStringOrNull())
val text = normalized((if ("text" in payload) payload["text"] else source["text"]).asStringOrNull())
if ((kind == "phase" || kind == "log") && text != null) {
if (kind == "phase") {
val rankKey = phaseRankKey(groupId, text)
if (rankKey !in phaseRankByGroupPhase) {
setBounded(phaseRankByGroupPhase, rankKey, nextPhaseRank++, MAX_TRACKED_SWARM_CHILDREN)
}
setBounded(currentPhaseByGroup, groupId, text, MAX_TRACKED_SWARM_GROUPS)
} else {
setBounded(latestLogByGroup, groupId, text, MAX_TRACKED_SWARM_GROUPS)
}
return true
}
val childKey = normalized(source["key"].asStringOrNull()) ?: normalized(payload["sessionKey"].asStringOrNull()) ?: return true
val explicitPhase = normalized(source["swarmPhase"].asStringOrNull()) ?: normalized(payload["swarmPhase"].asStringOrNull())
if (explicitPhase != null) {
setBounded(phaseByChild, childKey, explicitPhase, MAX_TRACKED_SWARM_CHILDREN)
return true
}
if (payload["reason"].asStringOrNull() == "create" && childKey !in phaseByChild) {
currentPhaseByGroup[groupId]?.let { phase ->
setBounded(phaseByChild, childKey, phase, MAX_TRACKED_SWARM_CHILDREN)
}
}
return true
}
fun decorate(rows: List<ChatSessionEntry>): List<ChatSessionEntry> =
rows.map { row ->
val groupId = normalized(row.swarmGroupId) ?: return@map row
val phase = phaseByChild[row.key] ?: row.swarmPhase
row.copy(
swarmPhase = phase,
swarmPhaseRank = phase?.let { phaseRankByGroupPhase[phaseRankKey(groupId, it)] } ?: row.swarmPhaseRank,
swarmLog = latestLogByGroup[groupId] ?: row.swarmLog,
)
}
private fun phaseRankKey(
groupId: String,
phase: String,
): String = "${groupId.length}:$groupId$phase"
private fun normalized(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
private fun <V> setBounded(
values: LinkedHashMap<String, V>,
key: String,
value: V,
limit: Int,
) {
if (key !in values && values.size >= limit) {
values.entries
.firstOrNull()
?.key
?.let(values::remove)
}
values[key] = value
}
}
internal fun buildChatSwarmGroups(
sessions: List<ChatSessionEntry>,
matchesParent: (String) -> Boolean,
): List<ChatSwarmGroup> {
data class Entry(
val phase: String?,
val phaseRank: Int,
val log: String?,
val dot: ChatSwarmDot,
)
val byGroup = linkedMapOf<String, MutableList<Entry>>()
sessions.forEach { row ->
val groupId = row.swarmGroupId?.trim()?.takeIf(String::isNotEmpty) ?: return@forEach
if (!belongsToParent(row, groupId, matchesParent)) return@forEach
val status = swarmDotStatus(row) ?: return@forEach
val label = listOfNotNull(row.label, row.displayName, row.derivedTitle, row.key).firstOrNull { it.isNotBlank() } ?: row.key
byGroup.getOrPut(groupId, ::mutableListOf) +=
Entry(
phase = row.swarmPhase?.trim()?.takeIf(String::isNotEmpty),
phaseRank = row.swarmPhaseRank ?: Int.MAX_VALUE,
log = row.swarmLog?.trim()?.takeIf(String::isNotEmpty),
dot = ChatSwarmDot(key = row.key, label = label, status = status),
)
}
return byGroup
.map { (groupId, entries) ->
val phases = linkedMapOf<String, Triple<String?, Int, MutableList<ChatSwarmDot>>>()
entries.forEach { entry ->
val phaseKey = entry.phase ?: ""
val existing = phases[phaseKey]
if (existing == null) {
phases[phaseKey] = Triple(entry.phase, entry.phaseRank, mutableListOf(entry.dot))
} else {
existing.third += entry.dot
phases[phaseKey] = Triple(existing.first, minOf(existing.second, entry.phaseRank), existing.third)
}
}
val projectedPhases =
phases
.map { (key, bucket) ->
val dots =
if (bucket.third.size > MAX_RENDERED_SWARM_DOTS_PER_PHASE) {
bucket.third.sortedBy(ChatSwarmDot::status)
} else {
bucket.third
}
ChatSwarmPhase(
key = key,
title = bucket.first,
dots = dots.take(MAX_RENDERED_SWARM_DOTS_PER_PHASE),
hidden = (dots.size - MAX_RENDERED_SWARM_DOTS_PER_PHASE).coerceAtLeast(0),
)
}.sortedWith(compareBy<ChatSwarmPhase> { phases[it.key]?.second ?: Int.MAX_VALUE }.thenBy(ChatSwarmPhase::key))
val dots = entries.map(Entry::dot)
ChatSwarmGroup(
groupId = groupId,
label = groupId.substringAfterLast(':'),
running = dots.count { it.status == ChatSwarmDotStatus.Running },
done = dots.count { it.status == ChatSwarmDotStatus.Done },
failed = dots.count { it.status == ChatSwarmDotStatus.Failed },
narrator = entries.firstNotNullOfOrNull(Entry::log),
phases = projectedPhases,
)
}.filter { group ->
group.phases.any { phase -> phase.dots.any { it.status == ChatSwarmDotStatus.Queued || it.status == ChatSwarmDotStatus.Running } }
}.sortedBy(ChatSwarmGroup::groupId)
}
private fun swarmDotStatus(row: ChatSessionEntry): ChatSwarmDotStatus? =
when {
row.status == "running" || row.hasActiveRun == true -> ChatSwarmDotStatus.Running
row.status == "done" -> ChatSwarmDotStatus.Done
row.status == "failed" || row.status == "killed" || row.status == "timeout" -> ChatSwarmDotStatus.Failed
row.subagentRunState == "active" || row.hasActiveSubagentRun == true -> ChatSwarmDotStatus.Queued
else -> null
}
private fun belongsToParent(
row: ChatSessionEntry,
groupId: String,
matchesParent: (String) -> Boolean,
): Boolean {
if (row.parentSessionKey?.let(matchesParent) == true || row.spawnedBy?.let(matchesParent) == true) return true
val parts = groupId.split(':')
return parts.size > 2 && matchesParent(parts.drop(1).dropLast(1).joinToString(":"))
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)
?.takeIf { it.isString }
?.content

View file

@ -0,0 +1,535 @@
package ai.openclaw.app.chat
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.withTransaction
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.util.UUID
/** Upper bound of cached session rows per gateway across every agent owner. */
internal const val MAX_CACHED_SESSIONS = 50
/** Upper bound of cached transcript rows per session; only the newest messages are kept. */
internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200
@Serializable
private data class CachedMessageContent(
val type: String,
val text: String? = null,
val mimeType: String? = null,
val fileName: String? = null,
val artifactId: String? = null,
val url: String? = null,
val openUrl: String? = null,
val alt: String? = null,
val width: Int? = null,
val height: Int? = null,
val sizeBytes: Long? = null,
val durationMs: Long? = null,
val playback: String? = null,
)
/**
* Read-only offline cache of chat sessions and transcripts.
*
* The cache is disposable: it only speeds up cold open and enables offline browsing.
* Live responses replace cached data; the active deep session may be retained outside the newest
* session-list window so its transcript remains available offline.
*/
interface ChatTranscriptCache {
suspend fun loadLastDefaultAgentId(gatewayId: String): String?
suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
)
suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry>
suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage>
suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String? = null,
)
suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
)
/** Removes one session and its transcript, so gateway-side deletes also purge offline copies. */
suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
)
/** Removes every cached transcript row owned by one gateway identity. */
suspend fun clearGateway(gatewayId: String)
}
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class CachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val status: String?,
val startedAt: Long?,
val endedAt: Long?,
val runtimeMs: Long?,
val outputTokens: Long?,
val hasRunMetadata: Boolean,
// Preserves gateway list order so offline session rows render in the familiar order.
val rowOrder: Int,
)
@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "agentId", "sessionKey", "rowOrder"])
internal data class CachedMessageEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val rowOrder: Int,
val role: String,
// JSON array of text and managed-media references; attachment bytes are never persisted.
val textPartsJson: String,
val timestampMs: Long?,
// Kept so live history reconciliation can match cached rows by identity key.
val idempotencyKey: String?,
)
@Entity(tableName = "cached_gateway_owners", primaryKeys = ["gatewayId"])
internal data class CachedGatewayOwnerEntity(
val gatewayId: String,
val agentId: String,
)
@Dao
internal interface ChatCacheDao {
@Query("SELECT agentId FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun lastDefaultAgentId(gatewayId: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertGatewayOwner(row: CachedGatewayOwnerEntity)
@Query("DELETE FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun deleteGatewayOwner(gatewayId: String)
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId ORDER BY rowOrder ASC")
suspend fun sessions(
gatewayId: String,
agentId: String,
): List<CachedSessionEntity>
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun session(
gatewayId: String,
agentId: String,
sessionKey: String,
): CachedSessionEntity?
@Query(
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
)
suspend fun messages(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<CachedMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSessions(rows: List<CachedSessionEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(rows: List<CachedMessageEntity>)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun deleteSessions(
gatewayId: String,
agentId: String,
)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId")
suspend fun deleteSessionsForGateway(gatewayId: String)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId")
suspend fun deleteMessages(gatewayId: String)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteSessionRow(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun nextSessionRowOrder(
gatewayId: String,
agentId: String,
): Int
// Keeps the just-written session even when the cache is full: without the exclusion, a stub
// inserted at the highest rowOrder would be evicted immediately and deep-session transcripts
// could never be cached once MAX_CACHED_SESSIONS rows exist.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey != :keepSessionKey AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey != :keepSessionKey " +
"ORDER BY rowOrder ASC LIMIT :keep)",
)
suspend fun evictSessionsBeyondKeeping(
gatewayId: String,
agentId: String,
keepSessionKey: String,
keep: Int,
)
// Owner-local cleanup runs before the gateway-wide bound below; transcripts never outlive
// their corresponding session row.
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId)",
)
suspend fun evictOrphanedTranscripts(
gatewayId: String,
agentId: String,
)
// A gateway can expose many agent owners. Cap their aggregate cache by recent writes so
// switching owners cannot grow the disposable session/transcript tables without bound.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND rowid NOT IN " +
"(SELECT rowid FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowid DESC LIMIT :keep)",
)
suspend fun evictGatewaySessionsBeyond(
gatewayId: String,
keep: Int,
)
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND NOT EXISTS " +
"(SELECT 1 FROM cached_sessions WHERE cached_sessions.gatewayId = cached_messages.gatewayId " +
"AND cached_sessions.agentId = cached_messages.agentId " +
"AND cached_sessions.sessionKey = cached_messages.sessionKey)",
)
suspend fun evictGatewayOrphanedTranscripts(gatewayId: String)
}
/**
* Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured
* before their suspend point, so a connection switch cannot re-scope an old response.
*/
class RoomChatTranscriptCache internal constructor(
private val database: GatewayCacheDatabase,
) : ChatTranscriptCache {
private val json = Json
private val cachedContentSerializer = ListSerializer(CachedMessageContent.serializer())
private val legacyTextPartsSerializer = ListSerializer(String.serializer())
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? {
val gateway = scopedGatewayId(gatewayId) ?: return null
return database
.dao()
.lastDefaultAgentId(gateway)
?.trim()
?.takeIf { it.isNotEmpty() }
}
override suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
database.dao().upsertGatewayOwner(CachedGatewayOwnerEntity(gatewayId = gateway, agentId = agent))
}
override suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
val agent = scopedAgentId(agentId) ?: return emptyList()
return database.dao().sessions(gateway, agent).map { row ->
ChatSessionEntry(
key = row.sessionKey,
updatedAtMs = row.updatedAtMs,
ownerAgentId = agent,
displayName = row.displayName,
status = row.status,
startedAt = row.startedAt,
endedAt = row.endedAt,
runtimeMs = row.runtimeMs,
outputTokens = row.outputTokens,
hasRunMetadata = row.hasRunMetadata,
)
}
}
override suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
val agent = scopedAgentId(agentId) ?: return emptyList()
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return emptyList()
return database.dao().messages(gateway, agent, key).mapNotNull { row ->
val role = normalizeVisibleChatMessageRole(row.role) ?: return@mapNotNull null
ChatMessage(
id = UUID.randomUUID().toString(),
role = role,
content =
decodeCachedContent(row.textPartsJson).map { part ->
ChatMessageContent(
type = part.type,
text = part.text,
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
playback = part.playback,
)
},
timestampMs = row.timestampMs,
idempotencyKey = row.idempotencyKey,
// Canonical tree ids stay live-only; cached rows regain actions after history refresh.
entryId = null,
)
}
}
override suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String?,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val retainedKey = retainedSessionKey?.trim()?.takeIf { it.isNotEmpty() }
val dao = database.dao()
database.withTransaction {
val initialSessions = sessions.take(MAX_CACHED_SESSIONS)
val needsRetainedRow = retainedKey != null && initialSessions.none { it.key == retainedKey }
val retainedEntry = if (needsRetainedRow) sessions.firstOrNull { it.key == retainedKey } else null
val retainedRow =
if (needsRetainedRow) {
retainedEntry?.let { entry ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = entry.key,
displayName = entry.displayName,
updatedAtMs = entry.updatedAtMs,
status = entry.status,
startedAt = entry.startedAt,
endedAt = entry.endedAt,
runtimeMs = entry.runtimeMs,
outputTokens = entry.outputTokens,
hasRunMetadata = entry.hasRunMetadata,
rowOrder = 0,
)
} ?: dao.session(gateway, agent, retainedKey)
} else {
null
}
val listedSessionLimit = MAX_CACHED_SESSIONS - if (retainedRow == null) 0 else 1
val rows =
sessions.take(listedSessionLimit).mapIndexed { index, session ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = session.key,
displayName = session.displayName,
updatedAtMs = session.updatedAtMs,
status = session.status,
startedAt = session.startedAt,
endedAt = session.endedAt,
runtimeMs = session.runtimeMs,
outputTokens = session.outputTokens,
hasRunMetadata = session.hasRunMetadata,
rowOrder = index,
)
}
dao.deleteSessions(gateway, agent)
dao.insertSessions(rows)
retainedRow?.let { dao.insertSessions(listOf(it.copy(rowOrder = rows.size))) }
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
override suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
// Persist small managed-media references, never attachment bytes. Cards remain visible offline
// even though their short-lived download capability must be reacquired after reconnecting.
val rows =
messages
.mapNotNull { message ->
val role = normalizeVisibleChatMessageRole(message.role) ?: return@mapNotNull null
val content =
message.content.mapNotNull { part ->
when {
part.type == "text" && !part.text.isNullOrBlank() ->
CachedMessageContent(type = "text", text = part.text)
part.type == "image" && !part.artifactId.isNullOrBlank() && !part.url.isNullOrBlank() ->
CachedMessageContent(
type = "image",
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
)
part.type == "audio" || part.type == "video" ->
CachedMessageContent(
type = part.type,
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
playback = part.playback,
)
else -> null
}
}
if (content.isEmpty()) return@mapNotNull null
Triple(message, role, content)
}.takeLast(MAX_CACHED_MESSAGES_PER_SESSION)
.mapIndexed { index, (message, role, content) ->
CachedMessageEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
rowOrder = index,
role = role,
textPartsJson = json.encodeToString(cachedContentSerializer, content),
timestampMs = message.timestampMs,
idempotencyKey = message.idempotencyKey,
)
}
val dao = database.dao()
database.withTransaction {
dao.deleteTranscript(gateway, agent, key)
dao.insertMessages(rows)
// A transcript may arrive for a session missing from the cached list (e.g. deep session
// switch); keep a stub row so the transcript stays reachable, then re-apply the bounds.
val currentSession = dao.session(gateway, agent, key)
// REPLACE refreshes SQLite rowid, making the transcript's session the most recent gateway
// row while preserving list metadata when that session was already cached.
dao.insertSessions(
listOf(
currentSession
?: CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
displayName = null,
updatedAtMs = null,
status = null,
startedAt = null,
endedAt = null,
runtimeMs = null,
outputTokens = null,
hasRunMetadata = false,
rowOrder = dao.nextSessionRowOrder(gateway, agent),
),
),
)
dao.evictSessionsBeyondKeeping(gateway, agent, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1)
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
override suspend fun clearGateway(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
val dao = database.dao()
database.withTransaction {
dao.deleteMessages(gateway)
dao.deleteSessionsForGateway(gateway)
dao.deleteGatewayOwner(gateway)
}
}
override suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
val dao = database.dao()
database.withTransaction {
dao.deleteSessionRow(gateway, agent, key)
dao.deleteTranscript(gateway, agent, key)
}
}
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
private fun scopedAgentId(agentId: String): String? = agentId.trim().takeIf { it.isNotEmpty() }
private fun decodeCachedContent(encoded: String): List<CachedMessageContent> =
runCatching { json.decodeFromString(cachedContentSerializer, encoded) }.getOrElse {
// Offline transcript browsing is shipped behavior. Keep the previous string-array rows
// readable until a live history refresh naturally rewrites this disposable cache entry.
runCatching { json.decodeFromString(legacyTextPartsSerializer, encoded) }
.getOrDefault(emptyList())
.map { CachedMessageContent(type = "text", text = it) }
}
}

View file

@ -0,0 +1,193 @@
package ai.openclaw.app.chat
import java.net.URI
import java.net.URLDecoder
internal object ChatWidgetUrlResolver {
private const val DOCUMENTS_PATH = "/__openclaw__/canvas/documents"
fun resolve(
surfaceUrl: String?,
target: String,
): String? {
val surface = parseCapabilitySurface(surfaceUrl) ?: return null
val relative = parseRelativeTarget(target) ?: return null
val joined =
buildString {
append(surface.scheme.lowercase())
append("://")
append(surface.rawAuthority)
append(surface.rawPath.trimEnd('/'))
append(relative.rawPath)
relative.rawQuery?.let { append('?').append(it) }
relative.rawFragment?.let { append('#').append(it) }
}
return runCatching { URI(joined) }.getOrNull()?.toASCIIString()
}
fun supportsTarget(target: String): Boolean = parseRelativeTarget(target) != null
private fun resolve(
surface: ChatWidgetSurface,
target: String,
role: ChatWidgetSurfaceRole,
attemptedRoles: Set<ChatWidgetSurfaceRole>,
): ChatWidgetResource? =
resolve(surface.url, target)?.let { url ->
ChatWidgetResource(
url = url,
tlsFingerprintSha256 = surface.tlsFingerprintSha256,
surfaceRole = role,
attemptedSurfaceRoles = attemptedRoles,
)
}
fun resolvePreferred(
surfaces: ChatWidgetSurfaceUrls,
target: String,
excluding: ChatWidgetResource?,
blockedRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
attemptedRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
): ChatWidgetResource? =
sequenceOf(
ChatWidgetSurfaceRole.NODE to surfaces.node,
ChatWidgetSurfaceRole.OPERATOR to surfaces.operator,
).filter { (role) -> role !in blockedRoles }
.mapNotNull { (role, surface) -> surface?.let { resolve(it, target, role, attemptedRoles) } }
.firstOrNull { isReplacement(it, excluding) }
suspend fun resolveAfterFailure(
target: String,
failedResource: ChatWidgetResource,
currentSurfaceUrls: () -> ChatWidgetSurfaceUrls,
refreshNodeSurface: suspend (String?) -> ChatWidgetSurface?,
refreshOperatorSurface: suspend (String?) -> ChatWidgetSurface?,
): ChatWidgetResource? {
val observed = currentSurfaceUrls()
val blockedRoles = failedResource.attemptedSurfaceRoles
if (failedResource.surfaceRole == ChatWidgetSurfaceRole.LEGACY && ChatWidgetSurfaceRole.LEGACY in blockedRoles) {
return null
}
val attemptedRoles = blockedRoles + failedResource.surfaceRole
if (ChatWidgetSurfaceRole.NODE !in blockedRoles) {
observed.node
?.let { resolve(it, target, ChatWidgetSurfaceRole.NODE, attemptedRoles) }
?.takeIf { isReplacement(it, failedResource) }
?.let { return it }
val refreshed =
refreshNodeSurface(observed.node?.url)?.let {
resolve(it, target, ChatWidgetSurfaceRole.NODE, attemptedRoles)
}
if (refreshed != null && isReplacement(refreshed, failedResource)) return refreshed
}
// A nil refresh can mean its route lease lost a reconnect race. Re-read
// both roles so a replacement connection wins over the stale observation.
val afterNodeRefresh = currentSurfaceUrls()
resolvePreferred(
afterNodeRefresh,
target,
excluding = failedResource,
blockedRoles = blockedRoles,
attemptedRoles = attemptedRoles,
)?.let { return it }
if (ChatWidgetSurfaceRole.OPERATOR !in blockedRoles) {
val refreshedOperator =
refreshOperatorSurface(afterNodeRefresh.operator?.url)?.let {
resolve(it, target, ChatWidgetSurfaceRole.OPERATOR, attemptedRoles)
}
if (refreshedOperator != null && isReplacement(refreshedOperator, failedResource)) return refreshedOperator
}
return resolvePreferred(
currentSurfaceUrls(),
target,
excluding = failedResource,
blockedRoles = blockedRoles,
attemptedRoles = attemptedRoles,
)
}
private fun isReplacement(
candidate: ChatWidgetResource,
failedResource: ChatWidgetResource?,
): Boolean {
if (failedResource == null) return true
return if (
failedResource.surfaceRole == ChatWidgetSurfaceRole.LEGACY &&
failedResource.tlsFingerprintSha256 == null
) {
candidate.url != failedResource.url
} else {
candidate.url != failedResource.url ||
candidate.tlsFingerprintSha256 != failedResource.tlsFingerprintSha256
}
}
private fun parseCapabilitySurface(raw: String?): URI? {
val parsed = raw?.trim()?.takeIf(String::isNotEmpty)?.let { runCatching { URI(it) }.getOrNull() } ?: return null
val scheme = parsed.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return null
if (parsed.host.isNullOrBlank() || parsed.rawUserInfo != null || parsed.rawQuery != null || parsed.rawFragment != null) return null
val segments = parsed.rawPath.split('/').filter(String::isNotEmpty)
if (segments.size < 3 || segments[segments.lastIndex - 2] != "__openclaw__" || segments[segments.lastIndex - 1] != "cap") return null
if (decodeRepeatedly(segments.last())?.isEmpty() != false) return null
return parsed
}
private fun parseRelativeTarget(raw: String): URI? {
val target = raw.trim()
if (!target.startsWith('/')) return null
val parsed = runCatching { URI(target) }.getOrNull() ?: return null
if (parsed.isAbsolute || parsed.rawAuthority != null || !isCanonicalPath(parsed.rawPath)) return null
if (!parsed.rawPath.startsWith("$DOCUMENTS_PATH/")) return null
return parsed
}
private fun isCanonicalPath(path: String): Boolean {
val segments = path.split('/')
if (segments.firstOrNull()?.isNotEmpty() == true) return false
return segments.drop(1).all { encoded ->
if (encoded.isEmpty()) return@all false
val decoded = decodeRepeatedly(encoded) ?: return@all false
decoded != "." && decoded != ".." && !decoded.contains('/') && !decoded.contains('\\')
}
}
private fun decodeRepeatedly(raw: String): String? {
var value = raw
repeat(8) {
val decoded =
runCatching {
URLDecoder.decode(value.replace("+", "%2B"), Charsets.UTF_8.name())
}.getOrNull() ?: return null
if (decoded == value) return decoded
value = decoded
}
return null
}
}
internal data class ChatWidgetSurfaceUrls(
val node: ChatWidgetSurface?,
val operator: ChatWidgetSurface?,
)
internal data class ChatWidgetSurface(
val url: String,
val tlsFingerprintSha256: String?,
)
internal enum class ChatWidgetSurfaceRole {
NODE,
OPERATOR,
LEGACY,
}
internal data class ChatWidgetResource(
val url: String,
val tlsFingerprintSha256: String?,
val surfaceRole: ChatWidgetSurfaceRole = ChatWidgetSurfaceRole.LEGACY,
val attemptedSurfaceRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
)

View file

@ -0,0 +1,772 @@
package ai.openclaw.app.chat
import android.content.Context
import android.util.Log
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.room.withTransaction
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
internal const val GATEWAY_CACHE_DB_NAME = "gateway-cache.db"
internal const val CLIENT_STATE_DB_NAME = "client-state.db"
internal const val LEGACY_CHAT_DATABASE_NAME = "chat-transcript-cache.db"
private const val LEGACY_IMPORT_KEY = "legacy-chat-transcript-cache-v8"
private const val LEGACY_IMPORT_COMPLETE = "complete"
private const val LEGACY_IMPORT_CHUNK_PAGE_ROWS = 8
private const val GATEWAY_REMOVAL_STAGED = "staged"
private const val GATEWAY_REMOVAL_COMMITTING = "committing"
private const val GATEWAY_REMOVAL_CACHE_PENDING = "cache-pending"
@Entity(tableName = "client_state_metadata")
internal data class ClientStateMetadataEntity(
@PrimaryKey val key: String,
val value: String,
)
@Entity(tableName = "gateway_removals")
internal data class GatewayRemovalEntity(
@PrimaryKey val gatewayId: String,
val phase: String,
)
@Dao
internal interface ClientStateControlDao {
@Query("SELECT value FROM client_state_metadata WHERE `key` = :key")
suspend fun metadataValue(key: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertMetadata(row: ClientStateMetadataEntity)
@Query("SELECT * FROM gateway_removals ORDER BY gatewayId ASC")
suspend fun gatewayRemovals(): List<GatewayRemovalEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertGatewayRemoval(row: GatewayRemovalEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertGatewayRemovalIfAbsent(row: GatewayRemovalEntity)
@Query("DELETE FROM gateway_removals WHERE gatewayId = :gatewayId AND phase = :phase")
suspend fun deleteGatewayRemovalInPhase(
gatewayId: String,
phase: String,
)
}
/** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */
@Database(
entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class],
version = 2,
exportSchema = true,
)
internal abstract class GatewayCacheDatabase : RoomDatabase() {
abstract fun dao(): ChatCacheDao
companion object {
fun open(
context: Context,
name: String = GATEWAY_CACHE_DB_NAME,
): GatewayCacheDatabase {
val appContext = context.applicationContext
fun build(): GatewayCacheDatabase =
Room
.databaseBuilder(appContext, GatewayCacheDatabase::class.java, name)
// Cache rows are gateway-owned projections. A missing migration means rebuild, and
// dropAllTables also removes obsolete cache tables left by older formats.
.fallbackToDestructiveMigration(true)
.build()
var database: GatewayCacheDatabase? = null
return try {
build().also {
database = it
// Room opens lazily; force validation so corruption is repaired before publication.
it.openHelper.writableDatabase
}
} catch (_: Throwable) {
database?.close()
appContext.deleteDatabase(name)
build().also { it.openHelper.writableDatabase }
}
}
}
}
/** Durable client-owned state. Every future schema change requires an explicit Room migration. */
@Database(
entities = [
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
OutboxAttachmentChunkEntity::class,
ComposerSendAdmissionEntity::class,
ClientStateMetadataEntity::class,
GatewayRemovalEntity::class,
],
version = 1,
exportSchema = true,
)
internal abstract class ClientStateDatabase : RoomDatabase() {
abstract fun outboxDao(): ChatOutboxDao
abstract fun controlDao(): ClientStateControlDao
companion object {
fun open(
context: Context,
name: String = CLIENT_STATE_DB_NAME,
): ClientStateDatabase =
Room
.databaseBuilder(context.applicationContext, ClientStateDatabase::class.java, name)
.build()
.also {
// Fail closed and preserve the file if durable state cannot be opened or validated.
it.openHelper.writableDatabase
}
}
}
/**
* Shipped combined database, retained only as the one-time import owner.
*
* Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes.
*/
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class LegacyCachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val rowOrder: Int,
)
@Database(
entities = [
LegacyCachedSessionEntity::class,
CachedMessageEntity::class,
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
OutboxAttachmentChunkEntity::class,
ComposerSendAdmissionEntity::class,
CachedGatewayOwnerEntity::class,
],
version = 8,
exportSchema = false,
)
internal abstract class LegacyChatDatabase : RoomDatabase() {
abstract fun outboxDao(): ChatOutboxDao
companion object {
internal val MIGRATION_2_3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// v2 persisted every post-dispatch exception as queued+lastError. Those rows may
// already have run, so upgrading must park them alongside crash-interrupted sends.
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? OR (status = ? AND lastError IS NOT NULL)",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
ChatOutboxStatus.Sending.dbValue,
ChatOutboxStatus.Queued.dbValue,
),
)
}
}
internal val MIGRATION_3_4 =
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER")
// Legacy queued command-shaped rows predate connection epochs; the sentinel makes
// them park for explicit retry instead of silently replaying on the next reconnect.
db.execSQL(
"UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'",
arrayOf<Any?>(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue),
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " +
"`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " +
"`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
)
}
}
internal val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT")
// Agent-qualified keys carry a durable owner in the key itself. Backfill it so session
// deletion and replay keep working after upgrade without consulting mutable defaults.
db.execSQL(
"UPDATE outbox_commands SET ownerAgentId = " +
"substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " +
"WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1",
)
// Earlier rows did not persist the default agent that owned an unscoped key. Never
// guess after upgrade: queued input stays visible for manual resend, while accepted
// input remains delivery-ambiguous and must not be replayed under a different owner.
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_OWNER_CHANGED_ERROR,
ChatOutboxStatus.Queued.dbValue,
),
)
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
ChatOutboxStatus.Accepted.dbValue,
),
)
}
}
internal val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Session and transcript caches are disposable, and legacy unscoped rows have no
// provable owner. Rebuild both; the durable outbox remains intact across the upgrade.
db.execSQL("DROP TABLE IF EXISTS `cached_sessions`")
db.execSQL("DROP TABLE IF EXISTS `cached_messages`")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " +
"`timestampMs` INTEGER, `idempotencyKey` TEXT, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
)
}
}
internal val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
)
}
}
internal val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `composer_send_admissions` " +
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " +
"`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
}
}
fun open(
context: Context,
name: String,
): LegacyChatDatabase =
Room
.databaseBuilder(context.applicationContext, LegacyChatDatabase::class.java, name)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
// v1 contains only disposable transcripts. Durable state starts in v2.
.fallbackToDestructiveMigrationFrom(true, 1)
.build()
.also { it.openHelper.writableDatabase }
}
}
private class OpenedAndroidClientDatabases private constructor(
private val context: Context,
val gatewayCache: GatewayCacheDatabase,
val clientState: ClientStateDatabase,
) : AutoCloseable {
companion object {
suspend fun open(
context: Context,
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
clientStateName: String = CLIENT_STATE_DB_NAME,
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
registeredGatewayIds: Set<String>? = null,
): OpenedAndroidClientDatabases {
val appContext = context.applicationContext
val state = ClientStateDatabase.open(appContext, clientStateName)
var cache: GatewayCacheDatabase? = null
return try {
cache = GatewayCacheDatabase.open(appContext, gatewayCacheName)
OpenedAndroidClientDatabases(appContext, cache, state).also { databases ->
databases.importLegacyStateIfNeeded(legacyName)
databases.resolvePendingGatewayRemovals(registeredGatewayIds)
}
} catch (error: Throwable) {
cache?.close()
state.close()
throw error
}
}
}
val transcriptCache = RoomChatTranscriptCache(gatewayCache)
val commandOutbox = RoomChatCommandOutbox(clientState)
suspend fun stageGatewayRemoval(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
// A retry may race an interrupted committed purge. Never downgrade that irreversible marker.
clientState.controlDao().insertGatewayRemovalIfAbsent(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_STAGED))
}
suspend fun cancelGatewayRemoval(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
clientState.controlDao().deleteGatewayRemovalInPhase(gateway, GATEWAY_REMOVAL_STAGED)
}
/**
* Marks cleanup irreversible before touching either file. A crash can leave one database ahead
* of the other, so startup resumes this idempotent operation before publishing the stores.
*/
suspend fun commitGatewayRemoval(
gatewayId: String,
requireCacheRemoval: Boolean = false,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
withContext(NonCancellable) {
// State deletion and its phase advance are atomic. A rollback leaves no irreversible marker;
// after commit, startup may clear only disposable cache and must preserve any newer outbox rows.
clientState.withTransaction {
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_COMMITTING))
commandOutbox.clearGateway(gateway)
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_CACHE_PENDING))
}
completeCacheRemoval(gateway, propagateFailure = requireCacheRemoval)
}
}
override fun close() {
gatewayCache.close()
clientState.close()
}
private suspend fun importLegacyStateIfNeeded(legacyName: String) {
val control = clientState.controlDao()
if (control.metadataValue(LEGACY_IMPORT_KEY) == LEGACY_IMPORT_COMPLETE) {
context.deleteDatabase(legacyName)
return
}
val legacyFile = context.getDatabasePath(legacyName)
if (!legacyFile.exists()) {
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
return
}
val legacy = LegacyChatDatabase.open(context, legacyName)
try {
val source = legacy.outboxDao()
val commands = source.allCommands()
val admissions = source.allAdmissionReceipts()
val attachments = source.allAttachments()
clientState.withTransaction {
val destination = clientState.outboxDao()
if (commands.isNotEmpty()) destination.upsertImportedCommands(commands)
if (admissions.isNotEmpty()) destination.upsertImportedAdmissionReceipts(admissions)
if (attachments.isNotEmpty()) destination.upsertImportedAttachments(attachments)
}
var afterAttachmentId: String? = null
var afterChunkIndex = -1
while (true) {
val chunks = source.attachmentChunkPage(afterAttachmentId, afterChunkIndex, LEGACY_IMPORT_CHUNK_PAGE_ROWS)
if (chunks.isEmpty()) break
clientState.withTransaction {
clientState.outboxDao().upsertImportedAttachmentChunks(chunks)
}
chunks.last().let { cursor ->
afterAttachmentId = cursor.attachmentId
afterChunkIndex = cursor.chunkIndex
}
}
clientState.withTransaction {
// Earlier page commits are idempotent. This marker publishes them only after the source
// cursor is exhausted, so a crash simply replays REPLACE inserts on the next start.
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
}
} finally {
legacy.close()
}
// If deletion fails, the next open sees the completion marker and retries only deletion.
context.deleteDatabase(legacyName)
}
private suspend fun resolvePendingGatewayRemovals(registeredGatewayIds: Set<String>?) {
for (removal in clientState.controlDao().gatewayRemovals()) {
when {
removal.phase == GATEWAY_REMOVAL_CACHE_PENDING -> completeCacheRemoval(removal.gatewayId, propagateFailure = false)
removal.phase == GATEWAY_REMOVAL_COMMITTING -> commitGatewayRemoval(removal.gatewayId)
registeredGatewayIds != null && removal.gatewayId !in registeredGatewayIds ->
commitGatewayRemoval(removal.gatewayId)
registeredGatewayIds != null -> cancelGatewayRemoval(removal.gatewayId)
}
}
}
private suspend fun completeCacheRemoval(
gatewayId: String,
propagateFailure: Boolean,
) {
try {
transcriptCache.clearGateway(gatewayId)
} catch (error: Exception) {
if (propagateFailure) throw error
// Cache is disposable. Keep cache-pending for the next open, but the durable purge has
// committed and callers may safely retire auth without risking later outbox deletion.
Log.w("ClientDatabases", "Deferring gateway cache cleanup", error)
return
}
clientState.controlDao().deleteGatewayRemovalInPhase(gatewayId, GATEWAY_REMOVAL_CACHE_PENDING)
}
}
/**
* One installation-wide pair of multi-gateway databases initialized on a private IO scope.
* Every facade operation awaits the one-time legacy import before reaching either Room store.
*/
internal class AndroidClientDatabases private constructor(
private val scope: CoroutineScope,
private val initialization: Deferred<OpenedAndroidClientDatabases>,
private val openedReference: AtomicReference<OpenedAndroidClientDatabases?>,
private val closed: AtomicBoolean,
) : AutoCloseable {
companion object {
fun start(
context: Context,
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
clientStateName: String = CLIENT_STATE_DB_NAME,
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
registeredGatewayIds: Set<String>? = null,
): AndroidClientDatabases {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val openedReference = AtomicReference<OpenedAndroidClientDatabases?>()
val closed = AtomicBoolean(false)
val initialization =
scope.async {
val opened =
OpenedAndroidClientDatabases.open(
context = context.applicationContext,
gatewayCacheName = gatewayCacheName,
clientStateName = clientStateName,
legacyName = legacyName,
registeredGatewayIds = registeredGatewayIds,
)
if (closed.get()) {
opened.close()
throw CancellationException("Android client databases closed during initialization")
}
openedReference.set(opened)
if (closed.get() && openedReference.compareAndSet(opened, null)) {
opened.close()
throw CancellationException("Android client databases closed during initialization")
}
opened
}
return AndroidClientDatabases(scope, initialization, openedReference, closed)
}
}
private val transcriptCache = DeferredChatTranscriptCache(::ready)
private val commandOutbox = DeferredChatCommandOutbox(::ready)
fun transcriptCache(): ChatTranscriptCache = transcriptCache
fun commandOutbox(): ChatCommandOutbox = commandOutbox
suspend fun stageGatewayRemoval(gatewayId: String) = ready().stageGatewayRemoval(gatewayId)
suspend fun cancelGatewayRemoval(gatewayId: String) = ready().cancelGatewayRemoval(gatewayId)
suspend fun commitGatewayRemoval(
gatewayId: String,
requireCacheRemoval: Boolean = false,
) = ready().commitGatewayRemoval(gatewayId, requireCacheRemoval)
internal suspend fun gatewayCacheDatabase(): GatewayCacheDatabase = ready().gatewayCache
internal suspend fun clientStateDatabase(): ClientStateDatabase = ready().clientState
private suspend fun ready(): OpenedAndroidClientDatabases {
check(!closed.get()) { "Android client databases are closed" }
val opened = initialization.await()
check(!closed.get()) { "Android client databases are closed" }
return opened
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
scope.cancel()
openedReference.getAndSet(null)?.close()
}
}
private class DeferredChatTranscriptCache(
private val ready: suspend () -> OpenedAndroidClientDatabases,
) : ChatTranscriptCache {
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = ready().transcriptCache.loadLastDefaultAgentId(gatewayId)
override suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
) = ready().transcriptCache.saveLastDefaultAgentId(gatewayId, agentId)
override suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry> = ready().transcriptCache.loadSessions(gatewayId, agentId)
override suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage> = ready().transcriptCache.loadTranscript(gatewayId, agentId, sessionKey)
override suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String?,
) = ready().transcriptCache.saveSessions(gatewayId, agentId, sessions, retainedSessionKey)
override suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
) = ready().transcriptCache.saveTranscript(gatewayId, agentId, sessionKey, messages)
override suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
) = ready().transcriptCache.deleteSession(gatewayId, agentId, sessionKey)
override suspend fun clearGateway(gatewayId: String) = ready().transcriptCache.clearGateway(gatewayId)
}
private class DeferredChatCommandOutbox(
private val ready: suspend () -> OpenedAndroidClientDatabases,
) : ChatCommandOutbox {
override val supportsBranchCoordination: Boolean = true
override suspend fun load(gatewayId: String): List<ChatOutboxItem> = ready().commandOutbox.load(gatewayId)
override suspend fun wasAdmitted(id: String): Boolean = ready().commandOutbox.wasAdmitted(id)
override suspend fun enqueue(
gatewayId: String,
sessionKey: String,
text: String,
thinkingLevel: String,
nowMs: Long,
attachments: List<OutboxAttachmentPayload>,
gatedEpoch: Long?,
ownerAgentId: String,
idempotencyKey: String?,
): ChatOutboxEnqueueResult =
ready()
.commandOutbox
.enqueue(gatewayId, sessionKey, text, thinkingLevel, nowMs, attachments, gatedEpoch, ownerAgentId, idempotencyKey)
override suspend fun loadAttachments(id: String): List<LoadedOutboxAttachment> = ready().commandOutbox.loadAttachments(id)
override suspend fun updateStatus(
id: String,
status: ChatOutboxStatus,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.updateStatus(id, status, retryCount, lastError)
override suspend fun updateStatusIfAttempt(
id: String,
expectedAttemptVersion: Int,
status: ChatOutboxStatus,
retryCount: Int,
lastError: String?,
expectedStatus: ChatOutboxStatus?,
): Int = ready().commandOutbox.updateStatusIfAttempt(id, expectedAttemptVersion, status, retryCount, lastError, expectedStatus)
override suspend fun claimForSending(
id: String,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.claimForSending(id, retryCount, lastError)
override suspend fun claimForSendingIfAttempt(
id: String,
expectedAttemptVersion: Int,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.claimForSendingIfAttempt(id, expectedAttemptVersion, retryCount, lastError)
override suspend fun pinSessionKey(
id: String,
sessionKey: String,
) = ready().commandOutbox.pinSessionKey(id, sessionKey)
override suspend fun requeueForRetry(
gatewayId: String,
id: String,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String?,
): Int = ready().commandOutbox.requeueForRetry(gatewayId, id, nowMs, gatedEpoch, ownerAgentId)
override suspend fun requeueForRetryIfCurrent(
gatewayId: String,
id: String,
expectedAttemptVersion: Int,
expectedRetryCount: Int,
expectedLastError: String?,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String?,
replacementId: String?,
): Int =
ready()
.commandOutbox
.requeueForRetryIfCurrent(
gatewayId,
id,
expectedAttemptVersion,
expectedRetryCount,
expectedLastError,
nowMs,
gatedEpoch,
ownerAgentId,
replacementId,
)
override suspend fun delete(id: String) = ready().commandOutbox.delete(id)
override suspend fun deleteIfQueued(id: String): Boolean = ready().commandOutbox.deleteIfQueued(id)
override suspend fun confirmDelivered(ids: Set<String>): Int = ready().commandOutbox.confirmDelivered(ids)
override suspend fun confirmDeliveredAttempts(ids: Map<String, Int>): Int = ready().commandOutbox.confirmDeliveredAttempts(ids)
override suspend fun branchState(
gatewayId: String,
scope: ChatOutboxScope,
): ChatOutboxBranchState? = ready().commandOutbox.branchState(gatewayId, scope)
override suspend fun beginSessionMutation(
gatewayId: String,
scope: ChatOutboxScope,
nowMs: Long,
): ChatOutboxMutationLease? = ready().commandOutbox.beginSessionMutation(gatewayId, scope, nowMs)
override suspend fun cancelSessionMutation(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease,
): Boolean = ready().commandOutbox.cancelSessionMutation(gatewayId, scope, lease)
override suspend fun demoteSessionMutationToReconciliation(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease?,
): Boolean = ready().commandOutbox.demoteSessionMutationToReconciliation(gatewayId, scope, lease)
override suspend fun demoteSessionMutationToReconciliationState(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease?,
): ChatOutboxBranchState? = ready().commandOutbox.demoteSessionMutationToReconciliationState(gatewayId, scope, lease)
override suspend fun updateLastActiveLeafEntryId(
gatewayId: String,
scope: ChatOutboxScope,
leafEntryId: String,
expectedEpoch: Int,
expectedRevision: Int,
): Boolean = ready().commandOutbox.updateLastActiveLeafEntryId(gatewayId, scope, leafEntryId, expectedEpoch, expectedRevision)
override suspend fun reconcileBranchScope(
gatewayId: String,
scope: ChatOutboxScope,
previousState: ChatOutboxBranchState,
activeLeafEntryId: String?,
branchLeafEntryIds: Set<String>,
activeTranscriptEntryIds: Set<String>,
lastError: String,
): Boolean =
ready()
.commandOutbox
.reconcileBranchScope(
gatewayId,
scope,
previousState,
activeLeafEntryId,
branchLeafEntryIds,
activeTranscriptEntryIds,
lastError,
)
override suspend fun confirmBranchChange(
gatewayId: String,
scope: ChatOutboxScope,
activeLeafEntryId: String?,
lastError: String,
lease: ChatOutboxMutationLease?,
): Boolean = ready().commandOutbox.confirmBranchChange(gatewayId, scope, activeLeafEntryId, lastError, lease)
override suspend fun deleteForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
) = ready().commandOutbox.deleteForSession(gatewayId, sessionKey, ownerAgentId)
override suspend fun clearGateway(gatewayId: String) = ready().commandOutbox.clearGateway(gatewayId)
override suspend fun failSendingAfterRestart() = ready().commandOutbox.failSendingAfterRestart()
override suspend fun expireStale(
gatewayId: String,
nowMs: Long,
) = ready().commandOutbox.expireStale(gatewayId, nowMs)
}
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }

View file

@ -0,0 +1,303 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.voice.TalkAudioPlaying
import ai.openclaw.app.voice.TalkSpeakAudio
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "MessageSpeech"
internal enum class MessageSpeechPhase {
Preparing,
Speaking,
}
/** Playback state for the chat Listen action; null when idle. */
internal data class MessageSpeechState(
val messageId: String,
val phase: MessageSpeechPhase,
)
/** Renders message text to an audio clip; null means fall back to local TTS. */
internal fun interface MessageSpeechSynthesizing {
suspend fun synthesize(text: String): TalkSpeakAudio?
}
/** Speaks text with the on-device engine when the gateway cannot render audio. */
internal interface LocalSpeechSpeaking {
suspend fun speak(text: String)
fun stop()
}
/** Gateway tts.speak client using the general configured TTS provider chain. */
internal class MessageSpeechClient(
private val session: GatewaySession? = null,
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestDetailed: (suspend (String, String, Long) -> GatewaySession.RpcResult)? = null,
) : MessageSpeechSynthesizing {
override suspend fun synthesize(text: String): TalkSpeakAudio? {
val response =
try {
performRequest(
method = "tts.speak",
paramsJson = json.encodeToString(TtsSpeakRequest(text = text)),
timeoutMs = 60_000,
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.d(TAG, "tts.speak request failed: ${err.message ?: err::class.simpleName}")
return null
}
if (!response.ok) {
// Provider/config absence and older gateways both degrade to the on-device voice.
Log.d(TAG, "tts.speak unavailable: ${response.error?.message ?: "unknown error"}")
return null
}
val payload =
try {
json.decodeFromString<TtsSpeakResponse>(response.payloadJson ?: "")
} catch (err: Throwable) {
Log.d(TAG, "tts.speak payload invalid: ${err.message ?: err::class.simpleName}")
return null
}
val bytes =
try {
android.util.Base64.decode(payload.audioBase64, android.util.Base64.DEFAULT)
} catch (err: Throwable) {
Log.d(TAG, "tts.speak audio decode failed: ${err.message ?: err::class.simpleName}")
return null
}
if (bytes.isEmpty()) return null
return TalkSpeakAudio(
bytes = bytes,
provider = payload.provider,
outputFormat = payload.outputFormat,
voiceCompatible = null,
mimeType = payload.mimeType,
fileExtension = payload.fileExtension,
)
}
private suspend fun performRequest(
method: String,
paramsJson: String,
timeoutMs: Long,
): GatewaySession.RpcResult {
requestDetailed?.let { return it(method, paramsJson, timeoutMs) }
val activeSession = session ?: throw IllegalStateException("session missing")
return activeSession.requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
}
}
@Serializable
internal data class TtsSpeakRequest(
val text: String,
)
@Serializable
private data class TtsSpeakResponse(
val audioBase64: String,
val provider: String,
val outputFormat: String? = null,
val mimeType: String? = null,
val fileExtension: String? = null,
)
/** Drives one active chat Listen request, preferring gateway audio over local TTS. */
internal class MessageSpeechController(
private val scope: CoroutineScope,
private val synthesizer: MessageSpeechSynthesizing,
private val player: TalkAudioPlaying,
private val localSpeech: LocalSpeechSpeaking,
) {
private val _state = MutableStateFlow<MessageSpeechState?>(null)
val state: StateFlow<MessageSpeechState?> = _state.asStateFlow()
// A superseded playback's completion must not clear state owned by the next request.
private val generation = AtomicLong(0)
private var job: Job? = null
fun toggle(
messageId: String,
text: String,
) {
if (_state.value?.messageId == messageId) {
stop()
return
}
start(messageId = messageId, text = text)
}
fun stop() {
generation.incrementAndGet()
job?.cancel()
job = null
player.stop()
localSpeech.stop()
_state.value = null
}
private fun start(
messageId: String,
text: String,
) {
stop()
val spoken = text.trim()
if (spoken.isEmpty()) return
val token = generation.incrementAndGet()
_state.value = MessageSpeechState(messageId = messageId, phase = MessageSpeechPhase.Preparing)
job =
scope.launch {
try {
val clip = synthesizer.synthesize(spoken)
if (generation.get() != token) return@launch
_state.value = MessageSpeechState(messageId = messageId, phase = MessageSpeechPhase.Speaking)
if (!playClip(clip) && generation.get() == token) {
localSpeech.speak(spoken)
}
} finally {
if (generation.get() == token) _state.value = null
}
}
}
private suspend fun playClip(clip: TalkSpeakAudio?): Boolean {
if (clip == null) return false
return try {
player.play(clip)
true
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.w(TAG, "clip playback failed: ${err.message ?: err::class.simpleName}")
false
}
}
}
/** Minimal on-device TTS wrapper for the chat Listen fallback voice. */
internal class SystemSpeechSpeaker(
private val context: Context,
) : LocalSpeechSpeaking {
private val lock = Any()
private var engine: TextToSpeech? = null
private var ready: CompletableDeferred<Boolean>? = null
private var active: CompletableDeferred<Unit>? = null
override suspend fun speak(text: String) {
val engine = ensureEngine() ?: return
val utteranceId = "chat-listen-${System.nanoTime()}"
val done = CompletableDeferred<Unit>()
synchronized(lock) {
active?.cancel()
active = done
}
withContext(Dispatchers.Main.immediate) {
engine.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) {
if (id == utteranceId) done.complete(Unit)
}
@Deprecated("Deprecated in Java")
override fun onError(id: String?) {
if (id == utteranceId) done.complete(Unit)
}
override fun onError(
id: String?,
errorCode: Int,
) {
if (id == utteranceId) done.complete(Unit)
}
},
)
if (engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) != TextToSpeech.SUCCESS) {
done.complete(Unit)
}
}
try {
done.await()
} finally {
synchronized(lock) {
if (active === done) active = null
}
}
}
override fun stop() {
synchronized(lock) {
active?.cancel()
active = null
}
engine?.stop()
}
private suspend fun ensureEngine(): TextToSpeech? {
val current = synchronized(lock) { ready }
val pending = current ?: createEngine()
if (pending.await()) return synchronized(lock) { engine }
// A failed Android TTS service can recover later; do not cache its failed initialization.
val failedEngine =
synchronized(lock) {
if (ready !== pending) return null
ready = null
engine.also { engine = null }
}
withContext(Dispatchers.Main.immediate) { failedEngine?.shutdown() }
return null
}
private suspend fun createEngine(): CompletableDeferred<Boolean> {
val pending = CompletableDeferred<Boolean>()
val ownsInitialization =
synchronized(lock) {
if (ready != null) {
false
} else {
ready = pending
true
}
}
if (!ownsInitialization) return synchronized(lock) { checkNotNull(ready) }
val created =
try {
// Finish publishing the constructed engine even if the Listen job is stopped mid-init.
withContext(NonCancellable + Dispatchers.Main.immediate) {
TextToSpeech(context) { status -> pending.complete(status == TextToSpeech.SUCCESS) }
}
} catch (err: Throwable) {
Log.d(TAG, "system TTS initialization failed: ${err.message ?: err::class.simpleName}")
pending.complete(false)
null
}
synchronized(lock) {
if (ready === pending) engine = created else created?.shutdown()
}
return pending
}
}

View file

@ -0,0 +1,317 @@
package ai.openclaw.app.chat
import ai.openclaw.app.voice.TalkAudioLevel
import android.content.Context
import android.media.MediaRecorder
import android.os.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.io.RandomAccessFile
import java.util.UUID
internal const val VOICE_NOTE_MAX_DURATION_MS = 180_000L
internal const val VOICE_NOTE_MAX_BYTES = 3_500_000L
internal const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
internal sealed interface VoiceNoteRecorderState {
data object Idle : VoiceNoteRecorderState
data class Recording(
val startedAtMillis: Long,
) : VoiceNoteRecorderState
data object Preparing : VoiceNoteRecorderState
data class Failure(
val message: String,
) : VoiceNoteRecorderState
}
internal data class VoiceNoteRecording(
val file: File,
val durationMs: Long,
val id: String = file.absolutePath,
)
internal interface VoiceNoteRecordingEngine {
fun start(outputFile: File)
fun stop(): Long
fun cancel()
/** Peak abs PCM amplitude (0..32767) since the last poll; 0 when not recording. */
fun pollAmplitude(): Int
}
/** Owns voice-note recording state and temporary-file cleanup. */
internal class VoiceNoteRecorderController(
private val scope: CoroutineScope,
private val outputDirectory: File,
private val engine: VoiceNoteRecordingEngine,
private val requestPermission: suspend () -> Boolean,
private val acquireMic: () -> Boolean,
private val releaseMic: () -> Unit,
private val onFinished: (VoiceNoteRecording) -> Unit,
private val elapsedRealtimeMillis: () -> Long = SystemClock::elapsedRealtime,
) {
private val lock = Any()
private val _state = MutableStateFlow<VoiceNoteRecorderState>(VoiceNoteRecorderState.Idle)
val state: StateFlow<VoiceNoteRecorderState> = _state.asStateFlow()
private val _elapsedMs = MutableStateFlow(0L)
val elapsedMs: StateFlow<Long> = _elapsedMs.asStateFlow()
private val _inputLevel = MutableStateFlow(0f)
val inputLevel: StateFlow<Float> = _inputLevel.asStateFlow()
private var outputFile: File? = null
private var recordingId: String? = null
private var elapsedJob: Job? = null
private var ownsMic = false
suspend fun start(id: String = UUID.randomUUID().toString()): Boolean {
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Idle && _state.value !is VoiceNoteRecorderState.Failure) return false
}
if (!requestPermission()) {
fail("Microphone permission is required to record a voice note.")
return false
}
return synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Idle && _state.value !is VoiceNoteRecorderState.Failure) {
return@synchronized false
}
if (!acquireMic()) {
failLocked("Voice capture is already using the microphone.")
return@synchronized false
}
ownsMic = true
val startedAt = elapsedRealtimeMillis()
val file = File(outputDirectory, "voice-note-$id.m4a")
try {
engine.start(file)
} catch (_: Throwable) {
engine.cancel()
releaseMicLocked()
file.delete()
failLocked("Could not start voice-note recording.")
return@synchronized false
}
outputFile = file
recordingId = id
_elapsedMs.value = 0L
_state.value = VoiceNoteRecorderState.Recording(startedAtMillis = startedAt)
startElapsedUpdates(startedAt)
true
}
}
fun finish(): Boolean {
val recording =
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Recording) return false
val file = outputFile ?: return false
val id = recordingId ?: return false
elapsedJob?.cancel()
elapsedJob = null
val durationMs =
try {
engine.stop().coerceIn(0L, VOICE_NOTE_MAX_DURATION_MS)
} catch (_: Throwable) {
engine.cancel()
file.delete()
finishFailureLocked("Could not finish voice-note recording.")
return false
}
try {
normalizeM4aContainerBrand(file)
} catch (_: Throwable) {
file.delete()
finishFailureLocked("Could not finish voice-note recording.")
return false
}
if (file.length() > VOICE_NOTE_MAX_BYTES) {
file.delete()
finishFailureLocked("Voice note is too large. Record a shorter message.")
return false
}
// The file stays owned by the controller through Preparing: the staging
// coroutine is composition-scoped and may be cancelled before it runs,
// so cancel() must still be able to delete the handed-off recording.
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Preparing
releaseMicLocked()
VoiceNoteRecording(file = file, durationMs = durationMs, id = id)
}
onFinished(recording)
return true
}
fun completePreparation() {
synchronized(lock) {
if (_state.value is VoiceNoteRecorderState.Preparing) {
outputFile = null
recordingId = null
_state.value = VoiceNoteRecorderState.Idle
}
}
}
fun canCommitPreparation(id: String): Boolean =
synchronized(lock) {
_state.value is VoiceNoteRecorderState.Preparing && recordingId == id
}
fun cancel() {
synchronized(lock) {
elapsedJob?.cancel()
elapsedJob = null
if (_state.value is VoiceNoteRecorderState.Recording) {
engine.cancel()
}
releaseMicLocked()
outputFile?.delete()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Idle
}
}
fun reportFailure(message: String) {
synchronized(lock) {
outputFile?.delete()
outputFile = null
recordingId = null
failLocked(message)
}
}
private fun startElapsedUpdates(startedAt: Long) {
elapsedJob?.cancel()
elapsedJob =
scope.launch {
while (isActive && state.value is VoiceNoteRecorderState.Recording) {
val elapsed = (elapsedRealtimeMillis() - startedAt).coerceIn(0L, VOICE_NOTE_MAX_DURATION_MS)
_elapsedMs.value = elapsed
// MediaRecorder exposes only the peak since the last poll; running it
// through the shared dB window keeps this wave on the same visual
// scale as the RMS-metered talk and dictation waves.
val rawLevel = TalkAudioLevel.normalized((engine.pollAmplitude().coerceIn(0, 32_767)) / 32_767.0)
_inputLevel.value = TalkAudioLevel.smoothed(_inputLevel.value, rawLevel)
// MediaRecorder's duration callback races its asynchronous auto-stop.
// Own the cap here so every successful finish calls stop() exactly once.
if (elapsed >= VOICE_NOTE_MAX_DURATION_MS) {
finish()
return@launch
}
delay(100L)
}
}
}
private fun fail(message: String) {
synchronized(lock) { failLocked(message) }
}
private fun failLocked(message: String) {
_state.value = VoiceNoteRecorderState.Failure(message)
}
private fun finishFailureLocked(message: String) {
releaseMicLocked()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Failure(message)
}
private fun releaseMicLocked() {
if (!ownsMic) return
ownsMic = false
releaseMic()
}
}
/** Marks AAC-only MPEG-4 output as audio so gateway byte sniffing cannot classify it as video. */
internal fun normalizeM4aContainerBrand(file: File) {
RandomAccessFile(file, "rw").use { output ->
if (output.length() < 12L) return
output.seek(4L)
val boxType = ByteArray(4)
output.readFully(boxType)
if (!boxType.contentEquals("ftyp".toByteArray(Charsets.US_ASCII))) return
output.seek(8L)
output.write("M4A ".toByteArray(Charsets.US_ASCII))
}
}
/** Android AAC/m4a engine kept behind [VoiceNoteRecordingEngine] for JVM tests. */
internal class AndroidVoiceNoteRecordingEngine(
private val context: Context,
private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime,
) : VoiceNoteRecordingEngine {
private var recorder: MediaRecorder? = null
private var startedAtElapsedMs = 0L
override fun start(outputFile: File) {
check(recorder == null)
val next = MediaRecorder(context)
try {
next.setAudioSource(MediaRecorder.AudioSource.MIC)
next.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
next.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
next.setAudioChannels(1)
next.setAudioEncodingBitRate(32_000)
next.setAudioSamplingRate(44_100)
next.setOutputFile(outputFile.absolutePath)
next.prepare()
next.start()
startedAtElapsedMs = elapsedRealtime()
recorder = next
} catch (error: Throwable) {
next.release()
throw error
}
}
override fun stop(): Long {
val active = checkNotNull(recorder)
recorder = null
return try {
active.stop()
(elapsedRealtime() - startedAtElapsedMs).coerceAtLeast(0L)
} finally {
active.release()
}
}
override fun cancel() {
val active = recorder ?: return
recorder = null
runCatching { active.stop() }
active.release()
}
// maxAmplitude throws until sampling starts on some OEMs; a dead meter beats
// killing the recording.
override fun pollAmplitude(): Int = recorder?.let { active -> runCatching { active.maxAmplitude }.getOrDefault(0) } ?: 0
}

Some files were not shown because too many files have changed in this diff Show more