mirror of
https://github.com/BrendanGreenlee/openclaw-android-heartbeat.git
synced 2026-08-17 16:49:14 +00:00
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:
commit
7a380c40ed
656 changed files with 209982 additions and 0 deletions
16
.editorconfig
Normal file
16
.editorconfig
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal 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
59
CHANGELOG.md
Normal 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
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
15
Config/ReleaseSigning.json
Normal file
15
Config/ReleaseSigning.json
Normal 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"
|
||||
]
|
||||
}
|
||||
6
Config/Version.properties
Normal file
6
Config/Version.properties
Normal 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
386
README.md
Normal 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.
|
||||
16
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt
Normal file
16
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Compose.txt
Normal 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.
|
||||
18
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt
Normal file
18
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Media3.txt
Normal 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.
|
||||
182
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt
Normal file
182
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Room.txt
Normal 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
|
||||
19
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt
Normal file
19
THIRD_PARTY_LICENSES/openclaw/licenses/AndroidX Wear.txt
Normal 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.
|
||||
|
|
@ -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.
|
||||
19
THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt
Normal file
19
THIRD_PARTY_LICENSES/openclaw/licenses/Coil.txt
Normal 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.
|
||||
32
THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt
Normal file
32
THIRD_PARTY_LICENSES/openclaw/licenses/CommonMark Java.txt
Normal 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.
|
||||
110
THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt
Normal file
110
THIRD_PARTY_LICENSES/openclaw/licenses/KaTeX.txt
Normal 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.
|
||||
184
THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt
Normal file
184
THIRD_PARTY_LICENSES/openclaw/licenses/Kotlin Libraries.txt
Normal 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
|
||||
93
THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt
Normal file
93
THIRD_PARTY_LICENSES/openclaw/licenses/Manrope.txt
Normal 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.
|
||||
184
THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt
Normal file
184
THIRD_PARTY_LICENSES/openclaw/licenses/OkHttp and Okio.txt
Normal 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
|
||||
25
THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt
Normal file
25
THIRD_PARTY_LICENSES/openclaw/licenses/SLF4J API.txt
Normal 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.
|
||||
34
THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt
Normal file
34
THIRD_PARTY_LICENSES/openclaw/licenses/dnsjava.txt
Normal 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.
|
||||
23
THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt
Normal file
23
THIRD_PARTY_LICENSES/openclaw/licenses/nibor autolink.txt
Normal 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
115
VERSIONING.md
Normal 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
478
app/build.gradle.kts
Normal 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
13
app/lint.xml
Normal 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
8
app/proguard-rules.pro
vendored
Normal 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
|
||||
273
app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json
Normal file
273
app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
146
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json
Normal file
146
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
177
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json
Normal file
177
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
32
app/src/debug/AndroidManifest.xml
Normal file
32
app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
242
app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt
Normal file
242
app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
160
app/src/main/AndroidManifest.xml
Normal file
160
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
37
app/src/main/assets/katex/README.md
Normal file
37
app/src/main/assets/katex/README.md
Normal 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
|
||||
```
|
||||
BIN
app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
Binary file not shown.
20
app/src/main/assets/katex/index.html
Normal file
20
app/src/main/assets/katex/index.html
Normal 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>
|
||||
1
app/src/main/assets/katex/katex.min.css
vendored
Normal file
1
app/src/main/assets/katex/katex.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/katex/katex.min.js
vendored
Normal file
1
app/src/main/assets/katex/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
app/src/main/assets/katex/renderer.js
Normal file
34
app/src/main/assets/katex/renderer.js
Normal 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 }),
|
||||
);
|
||||
}
|
||||
};
|
||||
40
app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt
Normal file
40
app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt
Normal 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" }
|
||||
436
app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt
Normal file
436
app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt
Normal 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 {
|
||||
"I’ll 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()
|
||||
}
|
||||
33
app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt
Normal file
33
app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt
Normal 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))
|
||||
}
|
||||
98
app/src/main/java/ai/openclaw/app/AppLanguage.kt
Normal file
98
app/src/main/java/ai/openclaw/app/AppLanguage.kt
Normal 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)
|
||||
}
|
||||
25
app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt
Normal file
25
app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt
Normal 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
|
||||
}
|
||||
}
|
||||
223
app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
Normal file
223
app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
Normal 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
|
||||
16
app/src/main/java/ai/openclaw/app/CameraHudState.kt
Normal file
16
app/src/main/java/ai/openclaw/app/CameraHudState.kt
Normal 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,
|
||||
)
|
||||
316
app/src/main/java/ai/openclaw/app/CronJobDetail.kt
Normal file
316
app/src/main/java/ai/openclaw/app/CronJobDetail.kt
Normal 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
|
||||
671
app/src/main/java/ai/openclaw/app/CronJobManagement.kt
Normal file
671
app/src/main/java/ai/openclaw/app/CronJobManagement.kt
Normal 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
|
||||
28
app/src/main/java/ai/openclaw/app/DeviceNames.kt
Normal file
28
app/src/main/java/ai/openclaw/app/DeviceNames.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt
Normal file
41
app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt
Normal 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() }
|
||||
587
app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt
Normal file
587
app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt
Normal 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",
|
||||
)
|
||||
237
app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt
Normal file
237
app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/ai/openclaw/app/LocationMode.kt
Normal file
34
app/src/main/java/ai/openclaw/app/LocationMode.kt
Normal 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
|
||||
}
|
||||
382
app/src/main/java/ai/openclaw/app/MainActivity.kt
Normal file
382
app/src/main/java/ai/openclaw/app/MainActivity.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
1967
app/src/main/java/ai/openclaw/app/MainViewModel.kt
Normal file
1967
app/src/main/java/ai/openclaw/app/MainViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
123
app/src/main/java/ai/openclaw/app/NodeApp.kt
Normal file
123
app/src/main/java/ai/openclaw/app/NodeApp.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
469
app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
Normal file
469
app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
Normal 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)
|
||||
}
|
||||
9054
app/src/main/java/ai/openclaw/app/NodeRuntime.kt
Normal file
9054
app/src/main/java/ai/openclaw/app/NodeRuntime.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
517
app/src/main/java/ai/openclaw/app/PermissionRequester.kt
Normal file
517
app/src/main/java/ai/openclaw/app/PermissionRequester.kt
Normal 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
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/ai/openclaw/app/PhotoPermissions.kt
Normal file
23
app/src/main/java/ai/openclaw/app/PhotoPermissions.kt
Normal 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
|
||||
}
|
||||
802
app/src/main/java/ai/openclaw/app/SecurePrefs.kt
Normal file
802
app/src/main/java/ai/openclaw/app/SecurePrefs.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/ai/openclaw/app/SessionKey.kt
Normal file
37
app/src/main/java/ai/openclaw/app/SessionKey.kt
Normal 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(" · ")
|
||||
}
|
||||
238
app/src/main/java/ai/openclaw/app/SkillManagement.kt
Normal file
238
app/src/main/java/ai/openclaw/app/SkillManagement.kt
Normal 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)
|
||||
31
app/src/main/java/ai/openclaw/app/Utf16Text.kt
Normal file
31
app/src/main/java/ai/openclaw/app/Utf16Text.kt
Normal 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)
|
||||
}
|
||||
10
app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt
Normal file
10
app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt
Normal 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,
|
||||
}
|
||||
81
app/src/main/java/ai/openclaw/app/WorkspaceFiles.kt
Normal file
81
app/src/main/java/ai/openclaw/app/WorkspaceFiles.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
112
app/src/main/java/ai/openclaw/app/chat/BackgroundTask.kt
Normal file
112
app/src/main/java/ai/openclaw/app/chat/BackgroundTask.kt
Normal 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() }
|
||||
}
|
||||
1703
app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
Normal file
1703
app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
Normal file
File diff suppressed because it is too large
Load diff
65
app/src/main/java/ai/openclaw/app/chat/ChatComposerOwner.kt
Normal file
65
app/src/main/java/ai/openclaw/app/chat/ChatComposerOwner.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
7320
app/src/main/java/ai/openclaw/app/chat/ChatController.kt
Normal file
7320
app/src/main/java/ai/openclaw/app/chat/ChatController.kt
Normal file
File diff suppressed because it is too large
Load diff
309
app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
Normal file
309
app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
Normal 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,
|
||||
)
|
||||
111
app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt
Normal file
111
app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
295
app/src/main/java/ai/openclaw/app/chat/ChatSwarmProgress.kt
Normal file
295
app/src/main/java/ai/openclaw/app/chat/ChatSwarmProgress.kt
Normal 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
|
||||
535
app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
Normal file
535
app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
193
app/src/main/java/ai/openclaw/app/chat/ChatWidgetUrlResolver.kt
Normal file
193
app/src/main/java/ai/openclaw/app/chat/ChatWidgetUrlResolver.kt
Normal 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(),
|
||||
)
|
||||
772
app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt
Normal file
772
app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt
Normal 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() }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue