mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Merge branch 'main' into fix/memory-flush-not-executing
This commit is contained in:
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report a defect or unexpected behavior in OpenClaw.
|
||||
description: Report a non-regression defect or unexpected behavior in OpenClaw.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
@@ -8,6 +8,7 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
If this behavior worked previously and now fails, use the "Regression bug report" template instead.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
|
||||
115
.github/ISSUE_TEMPLATE/regression_bug_report.yml
vendored
Normal file
115
.github/ISSUE_TEMPLATE/regression_bug_report.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
name: Regression bug report
|
||||
description: Report behavior that worked previously and now fails in OpenClaw.
|
||||
title: "[Regression Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
- regression
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form only for regressions: behavior that previously worked and now fails.
|
||||
Keep reports concise, reproducible, and evidence-based.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what regressed.
|
||||
placeholder: Replies in Slack threads worked in <prior version> but fail in <current version>.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: last_known_good_version
|
||||
attributes:
|
||||
label: Last known good version
|
||||
description: Exact version/build where behavior still worked.
|
||||
placeholder: <version such as 2026.2.10>
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: first_bad_version
|
||||
attributes:
|
||||
label: First known bad version
|
||||
description: Exact version/build where behavior first failed.
|
||||
placeholder: <version such as 2026.2.17>
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: regression_window
|
||||
attributes:
|
||||
label: Regression window and trigger
|
||||
description: Describe when this started and what changed around that time.
|
||||
placeholder: Started after upgrading from <last-good-version> to <first-bad-version>; no config changes.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path.
|
||||
placeholder: |
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should happen if the regression does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happens now, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: OS and version where this occurs.
|
||||
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: install_method
|
||||
attributes:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include redacted logs/screenshots/recordings that prove the regression.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on <version>
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any context that helps triage but does not fit above.
|
||||
placeholder: Temporary workaround is rolling back to <last-known-good-version>.
|
||||
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
@@ -7,6 +7,7 @@ registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
token: ${{secrets.NPM_NPMJS_TOKEN}}
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
@@ -14,9 +15,9 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
@@ -36,9 +37,9 @@ updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
@@ -52,9 +53,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -68,9 +69,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -84,9 +85,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -100,9 +101,9 @@ updates:
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
@@ -118,7 +119,7 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
|
||||
9
.github/workflows/auto-response.yml
vendored
9
.github/workflows/auto-response.yml
vendored
@@ -19,13 +19,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const rules = [
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -404,6 +404,7 @@ jobs:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
|
||||
5
.github/workflows/install-smoke.yml
vendored
5
.github/workflows/install-smoke.yml
vendored
@@ -48,6 +48,11 @@ jobs:
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
|
||||
31
.github/workflows/labeler.yml
vendored
31
.github/workflows/labeler.yml
vendored
@@ -27,18 +27,25 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
@@ -127,7 +134,7 @@ jobs:
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
@@ -204,13 +211,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
@@ -444,13 +458,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
|
||||
9
.github/workflows/stale.yml
vendored
9
.github/workflows/stale.yml
vendored
@@ -16,13 +16,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -2,6 +2,98 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.27
|
||||
|
||||
### Changes
|
||||
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674)
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808)
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959)
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529)
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798)
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325)
|
||||
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494)
|
||||
- Feishu/Zalo runtime logging: replace direct `console.log/error` usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
|
||||
- Feishu/Post markdown parsing: parse rich-text `post` payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755)
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529)
|
||||
- Feishu/Post embedded media: extract `media` tags from inbound rich-text (`post`) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.
|
||||
- Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
|
||||
- Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups.<id>.allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.
|
||||
- Feishu/Group wildcard policy fallback: honor `channels.feishu.groups["*"]` when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.
|
||||
- Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.
|
||||
- Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when `document.convert` hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.
|
||||
- Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (`image` stays `image`, non-image maps to `file`) to prevent reintroducing unsupported Feishu `type=audio` fetches. (#16311, #8746) Thanks @Yaxuan42.
|
||||
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
|
||||
- TTS/Voice bubbles: use opus output and enable `audioAsVoice` routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.
|
||||
- Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
|
||||
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Nodes notification wake flow: enable Android `system.notify` default allowlist, emit `notifications.changed` events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
|
||||
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
|
||||
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
|
||||
- TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000.
|
||||
- TUI/SIGTERM shutdown: ignore `setRawMode EBADF` teardown errors during `SIGTERM` exit so long-running TUI sessions do not crash on terminal shutdown races, while still rethrowing unrelated stop errors. (#29430) Thanks @Cormazabal.
|
||||
- Memory/Hybrid recall: when strict hybrid scoring yields no hits, preserve keyword-backed matches using a text-weight floor so freshly indexed lexical canaries no longer disappear behind `minScore` filtering. (#29112) Thanks @ceo-nada.
|
||||
- Cron/Reminder session routing: preserve `job.sessionKey` for `sessionTarget="main"` runs so queued reminders wake and deliver in the originating scoped session/channel instead of being forced to the agent main session.
|
||||
- Agents/Sessions list transcript paths: resolve `sessions_list` `transcriptPath` via agent-aware session path options and ignore combined-store sentinel paths (`(multiple)`) so listed transcript paths always point to the state directory. (#28379) Thanks @fafuzuoluo.
|
||||
- Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc.
|
||||
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
|
||||
- Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc.
|
||||
- Android/Onboarding + voice reliability: request per-toggle onboarding permissions, update pairing guidance to `openclaw devices list/approve`, restore assistant speech playback in mic capture flow, cancel superseded in-flight speech (mute + per-reply token rotation), and keep `talk.config` loads retryable after transient failures. (#29796) Thanks @obviyus.
|
||||
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
||||
- Config/Doctor group allowlist diagnostics: align `groupPolicy: "allowlist"` warnings with per-channel runtime semantics by excluding Google Chat sender-list checks and by warning when no-fallback channels (for example iMessage) omit `groupAllowFrom`, with regression coverage. (#28477) Thanks @tonydehnke.
|
||||
- Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16.
|
||||
- Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001.
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
@@ -18,6 +110,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- FS tools/workspaceOnly: honor `tools.fs.workspaceOnly=false` for host write and edit operations so FS tools can access paths outside the workspace when sandbox is off. (#28822) thanks @lailoo. Fixes #28763. Thanks @cjscld for reporting.
|
||||
- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.
|
||||
- Gemini OAuth/Auth flow: align OAuth project discovery metadata and endpoint fallback handling for Gemini CLI auth, including fallback coverage for environment-provided project IDs. (#16684) Thanks @vincentkoc.
|
||||
@@ -36,6 +129,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
|
||||
- Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.
|
||||
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
|
||||
- Feishu/Merged forward parsing: expand inbound `merge_forward` messages by fetching and formatting API sub-messages in order, so merged forwards provide usable content context instead of only a placeholder line. (#28707) Thanks @tsu-builds.
|
||||
- Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.
|
||||
- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)
|
||||
- Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.
|
||||
@@ -89,6 +183,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
|
||||
- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation.
|
||||
- Memory/SQLite: deduplicate concurrent memory-manager initialization and auto-reopen stale SQLite handles after atomic reindex swaps, preventing repeated `attempt to write a readonly database` sync failures until gateway restart.
|
||||
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
|
||||
- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
|
||||
- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng.
|
||||
@@ -137,6 +232,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
|
||||
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
|
||||
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007)
|
||||
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool ... not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.
|
||||
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
|
||||
- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
|
||||
- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
|
||||
@@ -171,6 +267,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
@@ -278,6 +375,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
@@ -949,6 +1047,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore.
|
||||
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
||||
- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas.
|
||||
- Cron/Isolation: treat non-finite `nextRunAtMs` as missing and repair isolated `every` anchor fallback so legacy jobs without valid timestamps self-heal and scheduler wake timing remains valid. (#19469) Thanks @guirguispierre.
|
||||
- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204.
|
||||
- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204.
|
||||
- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204.
|
||||
|
||||
@@ -58,6 +58,8 @@ Welcome to the lobster tank! 🦞
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ RUN pnpm build
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
USER root
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Security hardening: Run as non-root user
|
||||
|
||||
@@ -32,9 +32,9 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
## Sponsors
|
||||
|
||||
| OpenAI | Blacksmith |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://blacksmith.sh/) |
|
||||
| OpenAI | Blacksmith | Convex |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>15221</sparkle:version>
|
||||
<sparkle:version>202602260</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
|
||||
@@ -150,6 +150,56 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## 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 (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`, approve latest pending device pairing request, then rerun:
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve --latest
|
||||
```
|
||||
|
||||
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` in this suite (Android requires interactive per-invocation screen-capture consent).
|
||||
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
|
||||
|
||||
Common failure quick-fixes:
|
||||
|
||||
- `pairing required` before tests start:
|
||||
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
|
||||
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
|
||||
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
|
||||
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
|
||||
|
||||
## Contributions
|
||||
|
||||
This Android app is currently being rebuilt.
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602260
|
||||
versionName = "2026.2.26"
|
||||
versionCode = 202602270
|
||||
versionName = "2026.2.27"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<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-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
|
||||
@@ -54,6 +54,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
||||
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
@@ -133,6 +134,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
runtime.setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.voice.MicCaptureManager
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -65,8 +66,6 @@ class NodeRuntime(context: Context) {
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
prefs = prefs,
|
||||
connectedEndpoint = { connectedEndpoint },
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
triggerCameraFlash = ::triggerCameraFlash,
|
||||
@@ -100,6 +99,26 @@ class NodeRuntime(context: Context) {
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val systemHandler: SystemHandler = SystemHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val photosHandler: PhotosHandler = PhotosHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val contactsHandler: ContactsHandler = ContactsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val calendarHandler: CalendarHandler = CalendarHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -122,6 +141,8 @@ class NodeRuntime(context: Context) {
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
@@ -133,6 +154,11 @@ class NodeRuntime(context: Context) {
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
systemHandler = systemHandler,
|
||||
photosHandler = photosHandler,
|
||||
contactsHandler = contactsHandler,
|
||||
calendarHandler = calendarHandler,
|
||||
motionHandler = motionHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -143,12 +169,15 @@ class NodeRuntime(context: Context) {
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
@@ -220,7 +249,12 @@ class NodeRuntime(context: Context) {
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
@@ -275,6 +309,14 @@ class NodeRuntime(context: Context) {
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
DeviceNotificationListenerService.setNodeEventSink { event, payloadJson ->
|
||||
scope.launch {
|
||||
nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val chat: ChatController =
|
||||
ChatController(
|
||||
scope = scope,
|
||||
@@ -282,6 +324,22 @@ class NodeRuntime(context: Context) {
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
)
|
||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
||||
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
|
||||
// without enabling the legacy talk capture loop.
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
).also { speaker ->
|
||||
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
|
||||
}
|
||||
}
|
||||
private val voiceReplySpeaker: TalkModeManager
|
||||
get() = voiceReplySpeakerLazy.value
|
||||
|
||||
private val micCapture: MicCaptureManager by lazy {
|
||||
MicCaptureManager(
|
||||
context = appContext,
|
||||
@@ -299,6 +357,9 @@ class NodeRuntime(context: Context) {
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -582,6 +643,16 @@ class NodeRuntime(context: Context) {
|
||||
externalAudioCaptureActive.value = value
|
||||
}
|
||||
|
||||
val speakerEnabled: StateFlow<Boolean>
|
||||
get() = prefs.speakerEnabled
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
prefs.setSpeakerEnabled(value)
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.setPlaybackEnabled(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
|
||||
@@ -99,6 +99,9 @@ class SecurePrefs(context: Context) {
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
@@ -270,6 +273,11 @@ class SecurePrefs(context: Context) {
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
@@ -173,6 +173,47 @@ class GatewaySession(
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
|
||||
val conn = currentConnection ?: return false
|
||||
val response =
|
||||
try {
|
||||
conn.request(
|
||||
"node.canvas.capability.refresh",
|
||||
params = buildJsonObject {},
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
if (!response.ok) {
|
||||
val err = response.error
|
||||
Log.w(
|
||||
"OpenClawGateway",
|
||||
"node.canvas.capability.refresh rejected: ${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
|
||||
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
|
||||
if (refreshedCapability.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
|
||||
return false
|
||||
}
|
||||
val scopedCanvasHostUrl = canvasHostUrl?.trim().orEmpty()
|
||||
if (scopedCanvasHostUrl.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing local canvasHostUrl")
|
||||
return false
|
||||
}
|
||||
val refreshedUrl = replaceCanvasCapabilityInScopedHostUrl(scopedCanvasHostUrl, refreshedCapability)
|
||||
if (refreshedUrl == null) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh unable to rewrite scoped canvas URL")
|
||||
return false
|
||||
}
|
||||
canvasHostUrl = refreshedUrl
|
||||
return true
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
@@ -501,11 +542,16 @@ class GatewaySession(
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result)
|
||||
sendInvokeResult(id, nodeId, result, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||
private suspend fun sendInvokeResult(
|
||||
id: String,
|
||||
nodeId: String,
|
||||
result: InvokeResult,
|
||||
invokeTimeoutMs: Long?,
|
||||
) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
@@ -527,10 +573,14 @@ class GatewaySession(
|
||||
)
|
||||
}
|
||||
}
|
||||
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||
Log.w(
|
||||
loggerTag,
|
||||
"node.invoke.result failed (ackTimeoutMs=$ackTimeoutMs): ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,3 +737,24 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceCanvasCapabilityInScopedHostUrl(
|
||||
scopedUrl: String,
|
||||
capability: String,
|
||||
): String? {
|
||||
val marker = "/__openclaw__/cap/"
|
||||
val markerStart = scopedUrl.indexOf(marker)
|
||||
if (markerStart < 0) return null
|
||||
val capabilityStart = markerStart + marker.length
|
||||
val slashEnd = scopedUrl.indexOf("/", capabilityStart).takeIf { it >= 0 }
|
||||
val queryEnd = scopedUrl.indexOf("?", capabilityStart).takeIf { it >= 0 }
|
||||
val fragmentEnd = scopedUrl.indexOf("#", capabilityStart).takeIf { it >= 0 }
|
||||
val capabilityEnd = listOfNotNull(slashEnd, queryEnd, fragmentEnd).minOrNull() ?: scopedUrl.length
|
||||
if (capabilityEnd <= capabilityStart) return null
|
||||
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)
|
||||
}
|
||||
|
||||
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
|
||||
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
|
||||
return normalized.coerceIn(15_000L, 120_000L)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.TimeZone
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALENDAR_LIMIT = 50
|
||||
|
||||
internal data class CalendarEventsRequest(
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class CalendarAddRequest(
|
||||
val title: String,
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val notes: String?,
|
||||
val calendarId: Long?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal data class CalendarEventRecord(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal interface CalendarDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
|
||||
|
||||
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
|
||||
}
|
||||
|
||||
private object SystemCalendarDataSource : CalendarDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, request.startMs)
|
||||
ContentUris.appendId(builder, request.endMs)
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
|
||||
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = mutableListOf<CalendarEventRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val id = cursor.getLong(0)
|
||||
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
|
||||
val beginMs = cursor.getLong(2)
|
||||
val endMs = cursor.getLong(3)
|
||||
val isAllDay = cursor.getInt(4) == 1
|
||||
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
|
||||
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
|
||||
out +=
|
||||
CalendarEventRecord(
|
||||
identifier = id.toString(),
|
||||
title = title,
|
||||
startISO = Instant.ofEpochMilli(beginMs).toString(),
|
||||
endISO = Instant.ofEpochMilli(endMs).toString(),
|
||||
isAllDay = isAllDay,
|
||||
location = location,
|
||||
calendarTitle = calendarTitle,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
|
||||
val resolver = context.contentResolver
|
||||
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
|
||||
put(CalendarContract.Events.TITLE, request.title)
|
||||
put(CalendarContract.Events.DTSTART, request.startMs)
|
||||
put(CalendarContract.Events.DTEND, request.endMs)
|
||||
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
|
||||
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
val eventId = uri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
return loadEventById(resolver, eventId)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
}
|
||||
|
||||
private fun resolveCalendarId(
|
||||
resolver: ContentResolver,
|
||||
calendarId: Long?,
|
||||
calendarTitle: String?,
|
||||
): Long {
|
||||
if (calendarId != null) {
|
||||
if (calendarExists(resolver, calendarId)) return calendarId
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
|
||||
}
|
||||
if (!calendarTitle.isNullOrEmpty()) {
|
||||
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
|
||||
}
|
||||
findDefaultCalendarId(resolver)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
|
||||
}
|
||||
|
||||
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars._ID}=?",
|
||||
arrayOf(id.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
return cursor != null && cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
|
||||
arrayOf(title),
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.VISIBLE}=1",
|
||||
null,
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEventById(
|
||||
resolver: ContentResolver,
|
||||
eventId: Long,
|
||||
): CalendarEventRecord? {
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Events._ID}=?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return CalendarEventRecord(
|
||||
identifier = cursor.getLong(0).toString(),
|
||||
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
|
||||
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
|
||||
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
|
||||
isAllDay = cursor.getInt(4) == 1,
|
||||
location = cursor.getString(5)?.trim()?.ifEmpty { null },
|
||||
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
|
||||
|
||||
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseEventsRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val events = dataSource.events(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"events",
|
||||
buildJsonArray { events.forEach { add(eventJson(it)) } },
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
if (request.title.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: title required",
|
||||
)
|
||||
}
|
||||
if (request.endMs <= request.startMs) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: endISO must be after startISO",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val event = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("event", eventJson(event))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
|
||||
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
|
||||
GatewaySession.InvokeResult.error(code = code, message = msg)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
val start = Instant.now()
|
||||
val end = start.plus(7, ChronoUnit.DAYS)
|
||||
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
val resolvedStart = start ?: Instant.now()
|
||||
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
|
||||
return CalendarEventsRequest(
|
||||
startMs = resolvedStart.toEpochMilli(),
|
||||
endMs = resolvedEnd.toEpochMilli(),
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
return CalendarAddRequest(
|
||||
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
|
||||
startMs = start.toEpochMilli(),
|
||||
endMs = end.toEpochMilli(),
|
||||
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
|
||||
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
|
||||
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseISO(raw: String?): Instant? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
return try {
|
||||
Instant.parse(value)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventJson(event: CalendarEventRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(event.identifier))
|
||||
put("title", JsonPrimitive(event.title))
|
||||
put("startISO", JsonPrimitive(event.startISO))
|
||||
put("endISO", JsonPrimitive(event.endISO))
|
||||
put("isAllDay", JsonPrimitive(event.isAllDay))
|
||||
event.location?.let { put("location", JsonPrimitive(it)) }
|
||||
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CalendarDataSource,
|
||||
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.util.Base64
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
@@ -30,6 +33,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -40,6 +47,12 @@ import kotlin.coroutines.resumeWithException
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
|
||||
data class CameraDeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: String,
|
||||
val deviceType: String,
|
||||
)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
@@ -52,6 +65,14 @@ class CameraCaptureManager(private val context: Context) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun listDevices(): List<CameraDeviceInfo> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val provider = context.cameraProvider()
|
||||
provider.availableCameraInfos
|
||||
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
|
||||
.sortedBy { it.id }
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
@@ -80,14 +101,15 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
val deviceId = parseDeviceId(params)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
@@ -145,12 +167,14 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val deviceId = parseDeviceId(params)
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio")
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
|
||||
@@ -162,8 +186,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
)
|
||||
.build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
// CameraX requires a Preview use case for the camera to start producing frames;
|
||||
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
|
||||
@@ -270,49 +293,104 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
paramsJson?.contains("\"back\"") == true -> "back"
|
||||
else -> null
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQuality(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
private fun resolveCameraSelector(
|
||||
provider: ProcessCameraProvider,
|
||||
facing: String,
|
||||
deviceId: String?,
|
||||
): CameraSelector {
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
}
|
||||
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
|
||||
if (!availableIds.contains(deviceId)) {
|
||||
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
|
||||
}
|
||||
return CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
|
||||
val cameraId = cameraIdOrNull(info) ?: return null
|
||||
val lensFacing =
|
||||
runCatching {
|
||||
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
|
||||
}.getOrNull()
|
||||
val position =
|
||||
when (lensFacing) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unspecified"
|
||||
}
|
||||
val deviceType =
|
||||
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
|
||||
val name =
|
||||
when (position) {
|
||||
"front" -> "Front Camera"
|
||||
"back" -> "Back Camera"
|
||||
"external" -> "External Camera"
|
||||
else -> "Camera $cameraId"
|
||||
}
|
||||
return CameraDeviceInfo(
|
||||
id = cameraId,
|
||||
name = name,
|
||||
position = position,
|
||||
deviceType = deviceType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cameraIdOrNull(info: CameraInfo): String? =
|
||||
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
|
||||
@@ -3,25 +3,57 @@ package ai.openclaw.android.node
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.CameraHudKind
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
|
||||
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val prefs: SecurePrefs,
|
||||
private val connectedEndpoint: () -> GatewayEndpoint?,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
private val triggerCameraFlash: () -> Unit,
|
||||
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
val devices = camera.listDevices()
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"devices",
|
||||
buildJsonArray {
|
||||
devices.forEach { device ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(device.id))
|
||||
put("name", JsonPrimitive(device.name))
|
||||
put("position", JsonPrimitive(device.position))
|
||||
put("deviceType", JsonPrimitive(device.deviceType))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
@@ -69,7 +101,7 @@ class CameraHandler(
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
@@ -89,62 +121,28 @@ class CameraHandler(
|
||||
showCameraHud(message, CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
// Upload file via HTTP instead of base64 through WebSocket
|
||||
clipLog("uploading via HTTP...")
|
||||
val uploadUrl = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ep = connectedEndpoint()
|
||||
val gatewayHost = if (ep != null) {
|
||||
val isHttps = ep.tlsEnabled || ep.port == 443
|
||||
if (!isHttps) {
|
||||
clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64")
|
||||
throw Exception("HTTPS required for upload (bearer token protection)")
|
||||
}
|
||||
if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}"
|
||||
} else {
|
||||
clipLog("error: no gateway endpoint connected, cannot upload")
|
||||
throw Exception("no gateway endpoint connected")
|
||||
}
|
||||
val token = prefs.loadGatewayToken() ?: ""
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val body = filePayload.file.asRequestBody("video/mp4".toMediaType())
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url("$gatewayHost/upload/clip.mp4")
|
||||
.put(body)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4")
|
||||
val resp = client.newCall(req).execute()
|
||||
val respBody = resp.body?.string() ?: ""
|
||||
clipLog("upload response: ${resp.code} $respBody")
|
||||
filePayload.file.delete()
|
||||
if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}")
|
||||
// Parse URL from response
|
||||
val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody)
|
||||
urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clipLog("upload failed: ${err.message}, falling back to base64")
|
||||
// Fallback to base64 if upload fails
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
val rawBytes = filePayload.file.length()
|
||||
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
|
||||
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
|
||||
withContext(Dispatchers.IO) { filePayload.file.delete() }
|
||||
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PAYLOAD_TOO_LARGE",
|
||||
message =
|
||||
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
|
||||
)
|
||||
}
|
||||
clipLog("returning URL result: $uploadUrl")
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
clipLog("returning base64 payload")
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
@@ -154,4 +152,24 @@ class CameraHandler(
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
val root =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val value =
|
||||
(root["includeAudio"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
|
||||
@@ -16,6 +15,8 @@ class ConnectionManager(
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
@@ -73,28 +74,20 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
private fun runtimeFlags(): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.Device.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(OpenClawCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
|
||||
|
||||
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
|
||||
|
||||
fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.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.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CONTACTS_LIMIT = 25
|
||||
|
||||
internal data class ContactRecord(
|
||||
val identifier: String,
|
||||
val displayName: String,
|
||||
val givenName: String,
|
||||
val familyName: String,
|
||||
val organizationName: String,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal data class ContactsSearchRequest(
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class ContactsAddRequest(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val organizationName: String?,
|
||||
val displayName: String?,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal interface ContactsDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
|
||||
|
||||
fun add(context: Context, request: ContactsAddRequest): ContactRecord
|
||||
}
|
||||
|
||||
private object SystemContactsDataSource : ContactsDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.Contacts._ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
|
||||
)
|
||||
val selection: String?
|
||||
val selectionArgs: Array<String>?
|
||||
if (request.query.isNullOrBlank()) {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
|
||||
selectionArgs = arrayOf("%${request.query}%")
|
||||
}
|
||||
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
|
||||
resolver.query(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
|
||||
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
|
||||
val out = mutableListOf<ContactRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val contactId = cursor.getLong(idIndex)
|
||||
val displayName = cursor.getString(displayNameIndex).orEmpty()
|
||||
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
|
||||
val resolver = context.contentResolver
|
||||
val operations = ArrayList<ContentProviderOperation>()
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
|
||||
.build()
|
||||
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, request.familyName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, request.displayName)
|
||||
.build()
|
||||
}
|
||||
if (!request.organizationName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
|
||||
.build()
|
||||
}
|
||||
request.phoneNumbers.forEach { number ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
|
||||
.build()
|
||||
}
|
||||
request.emails.forEach { email ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_HOME)
|
||||
.build()
|
||||
}
|
||||
|
||||
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
|
||||
val rawContactUri = results.firstOrNull()?.uri
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
return loadContactRecord(
|
||||
resolver = resolver,
|
||||
contactId = contactId,
|
||||
fallbackDisplayName = request.displayName.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
|
||||
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
|
||||
resolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.RawContacts._ID}=?",
|
||||
arrayOf(rawContactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
|
||||
return cursor.getLong(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContactRecord(
|
||||
resolver: ContentResolver,
|
||||
contactId: Long,
|
||||
fallbackDisplayName: String,
|
||||
): ContactRecord {
|
||||
val nameRow = loadNameRow(resolver, contactId)
|
||||
val organization = loadOrganization(resolver, contactId)
|
||||
val phones = loadPhones(resolver, contactId)
|
||||
val emails = loadEmails(resolver, contactId)
|
||||
val displayName =
|
||||
when {
|
||||
!nameRow.displayName.isNullOrEmpty() -> nameRow.displayName
|
||||
!fallbackDisplayName.isNullOrEmpty() -> fallbackDisplayName
|
||||
else -> listOfNotNull(nameRow.givenName, nameRow.familyName).joinToString(" ").trim()
|
||||
}.ifEmpty { "(unnamed)" }
|
||||
return ContactRecord(
|
||||
identifier = contactId.toString(),
|
||||
displayName = displayName,
|
||||
givenName = nameRow.givenName.orEmpty(),
|
||||
familyName = nameRow.familyName.orEmpty(),
|
||||
organizationName = organization.orEmpty(),
|
||||
phoneNumbers = phones,
|
||||
emails = emails,
|
||||
)
|
||||
}
|
||||
|
||||
private data class NameRow(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(
|
||||
contactId.toString(),
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return NameRow(givenName = null, familyName = null, displayName = null)
|
||||
}
|
||||
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
|
||||
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
|
||||
return NameRow(givenName = given, familyName = family, displayName = display)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: ContactsDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
|
||||
|
||||
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val contacts = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"contacts",
|
||||
buildJsonArray {
|
||||
contacts.forEach { add(contactJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contacts query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val hasName =
|
||||
!(request.givenName.isNullOrEmpty() && request.familyName.isNullOrEmpty() && request.displayName.isNullOrEmpty())
|
||||
val hasOrg = !request.organizationName.isNullOrEmpty()
|
||||
val hasDetails = request.phoneNumbers.isNotEmpty() || request.emails.isNotEmpty()
|
||||
if (!hasName && !hasOrg && !hasDetails) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_INVALID",
|
||||
message = "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val contact = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("contact", contactJson(contact))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contact add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): ContactsSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return ContactsSearchRequest(query = null, limit = DEFAULT_CONTACTS_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
|
||||
return ContactsSearchRequest(query = query, limit = limit)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): ContactsAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return ContactsAddRequest(
|
||||
givenName = (params["givenName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
familyName = (params["familyName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
|
||||
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringArray(array: JsonArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun contactJson(contact: ContactRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(contact.identifier))
|
||||
put("displayName", JsonPrimitive(contact.displayName))
|
||||
put("givenName", JsonPrimitive(contact.givenName))
|
||||
put("familyName", JsonPrimitive(contact.familyName))
|
||||
put("organizationName", JsonPrimitive(contact.organizationName))
|
||||
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
|
||||
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: ContactsDataSource,
|
||||
): ContactsHandler = ContactsHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ class DebugHandler(
|
||||
results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""")
|
||||
val diagnostics = results.joinToString("\n")
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
|
||||
} catch (e: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
@@ -11,6 +14,7 @@ import android.os.Environment
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
@@ -22,6 +26,13 @@ import kotlinx.serialization.json.put
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
) {
|
||||
private data class BatterySnapshot(
|
||||
val status: Int,
|
||||
val plugged: Int,
|
||||
val levelFraction: Double?,
|
||||
val temperatureC: Double?,
|
||||
)
|
||||
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
}
|
||||
@@ -30,12 +41,16 @@ class DeviceHandler(
|
||||
return GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val batteryStatus =
|
||||
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val batteryLevel = batteryLevelFraction(batteryIntent)
|
||||
val battery = readBatterySnapshot()
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val storage = StatFs(Environment.getDataDirectory().absolutePath)
|
||||
val totalBytes = storage.totalBytes
|
||||
@@ -50,8 +65,8 @@ class DeviceHandler(
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
batteryLevel?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
|
||||
battery.levelFraction?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
@@ -112,6 +127,204 @@ class DeviceHandler(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun permissionsPayloadJson(): String {
|
||||
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
val motionGranted =
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
val notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"camera",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.CAMERA),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"microphone",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.RECORD_AUDIO),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"location",
|
||||
permissionStateJson(
|
||||
granted =
|
||||
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"backgroundLocation",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"sms",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notificationListener",
|
||||
permissionStateJson(
|
||||
granted = notificationAccess,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notifications",
|
||||
permissionStateJson(
|
||||
granted = notificationsGranted,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"photos",
|
||||
permissionStateJson(
|
||||
granted = photosGranted,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"contacts",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CONTACTS),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"calendar",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALENDAR),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
granted = motionGranted,
|
||||
promptableWhenDenied = Build.VERSION.SDK_INT >= 29,
|
||||
),
|
||||
)
|
||||
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
|
||||
put(
|
||||
"screenCapture",
|
||||
permissionStateJson(
|
||||
granted = false,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun healthPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val batteryManager = appContext.getSystemService(BatteryManager::class.java)
|
||||
val currentNowUa = batteryManager?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||
val currentNowMa =
|
||||
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
|
||||
null
|
||||
} else {
|
||||
currentNowUa.toDouble() / 1_000.0
|
||||
}
|
||||
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val activityManager = appContext.getSystemService(ActivityManager::class.java)
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager?.getMemoryInfo(memoryInfo)
|
||||
val totalRamBytes = memoryInfo.totalMem.coerceAtLeast(0L)
|
||||
val availableRamBytes = memoryInfo.availMem.coerceAtLeast(0L)
|
||||
val usedRamBytes = (totalRamBytes - availableRamBytes).coerceAtLeast(0L)
|
||||
val lowMemory = memoryInfo.lowMemory
|
||||
val memoryPressure = mapMemoryPressure(totalRamBytes, availableRamBytes, lowMemory)
|
||||
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"memory",
|
||||
buildJsonObject {
|
||||
put("pressure", JsonPrimitive(memoryPressure))
|
||||
put("totalRamBytes", JsonPrimitive(totalRamBytes))
|
||||
put("availableRamBytes", JsonPrimitive(availableRamBytes))
|
||||
put("usedRamBytes", JsonPrimitive(usedRamBytes))
|
||||
put("thresholdBytes", JsonPrimitive(memoryInfo.threshold.coerceAtLeast(0L)))
|
||||
put("lowMemory", JsonPrimitive(lowMemory))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
put("chargingType", JsonPrimitive(mapChargingType(battery.plugged)))
|
||||
battery.temperatureC?.let { put("temperatureC", JsonPrimitive(it)) }
|
||||
currentNowMa?.let { put("currentMa", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
put(
|
||||
"power",
|
||||
buildJsonObject {
|
||||
put("dozeModeEnabled", JsonPrimitive(powerManager?.isDeviceIdleMode == true))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"system",
|
||||
buildJsonObject {
|
||||
Build.VERSION.SECURITY_PATCH
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { put("securityPatchLevel", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val status =
|
||||
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
|
||||
val temperatureC =
|
||||
intent
|
||||
?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
|
||||
?.takeIf { it != Int.MIN_VALUE }
|
||||
?.toDouble()
|
||||
?.div(10.0)
|
||||
return BatterySnapshot(
|
||||
status = status,
|
||||
plugged = plugged,
|
||||
levelFraction = batteryLevelFraction(intent),
|
||||
temperatureC = temperatureC,
|
||||
)
|
||||
}
|
||||
|
||||
private fun batteryLevelFraction(intent: Intent?): Double? {
|
||||
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
@@ -128,6 +341,16 @@ class DeviceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapChargingType(plugged: Int): String {
|
||||
return when (plugged) {
|
||||
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
|
||||
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
|
||||
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
|
||||
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
|
||||
else -> "none"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapThermalState(powerManager: PowerManager?): String {
|
||||
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
|
||||
return when (thermal) {
|
||||
@@ -150,6 +373,30 @@ class DeviceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
|
||||
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
|
||||
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
|
||||
if (lowMemory) return "critical"
|
||||
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
|
||||
return when {
|
||||
freeRatio <= 0.05 -> "critical"
|
||||
freeRatio <= 0.15 -> "high"
|
||||
freeRatio <= 0.30 -> "moderate"
|
||||
else -> "normal"
|
||||
}
|
||||
}
|
||||
|
||||
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
|
||||
buildJsonArray {
|
||||
if (caps == null) return@buildJsonArray
|
||||
|
||||
@@ -2,13 +2,19 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.RemoteInput
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
@@ -34,6 +40,28 @@ data class DeviceNotificationSnapshot(
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
enum class NotificationActionKind {
|
||||
Open,
|
||||
Dismiss,
|
||||
Reply,
|
||||
}
|
||||
|
||||
data class NotificationActionRequest(
|
||||
val key: String,
|
||||
val kind: NotificationActionKind,
|
||||
val replyText: String? = null,
|
||||
)
|
||||
|
||||
data class NotificationActionResult(
|
||||
val ok: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
|
||||
return kind == NotificationActionKind.Dismiss
|
||||
}
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
@@ -85,25 +113,71 @@ private object DeviceNotificationStore {
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
activeService = this
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
if (entry.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("posted"))
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
val removed = sbn ?: return
|
||||
val key = removed.key.trim()
|
||||
if (key.isEmpty()) {
|
||||
return
|
||||
}
|
||||
DeviceNotificationStore.remove(key)
|
||||
if (removed.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("removed"))
|
||||
put("key", JsonPrimitive(key))
|
||||
val packageName = removed.packageName.trim()
|
||||
if (packageName.isNotEmpty()) {
|
||||
put("packageName", JsonPrimitive(packageName))
|
||||
}
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
@@ -139,10 +213,17 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
|
||||
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
@@ -160,5 +241,125 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
if (!isAccessEnabled(context)) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_DISABLED",
|
||||
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
|
||||
)
|
||||
}
|
||||
val service = activeService
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_UNAVAILABLE",
|
||||
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
|
||||
)
|
||||
return service.executeActionInternal(request)
|
||||
}
|
||||
|
||||
private fun emitNotificationsChanged(payloadJson: String) {
|
||||
runCatching {
|
||||
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
|
||||
val sbn =
|
||||
activeNotifications
|
||||
?.firstOrNull { it.key == request.key }
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
if (actionRequiresClearableNotification(request.kind) && !sbn.isClearable) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_CLEARABLE",
|
||||
message = "NOTIFICATION_NOT_CLEARABLE: notification is ongoing or protected",
|
||||
)
|
||||
}
|
||||
|
||||
return when (request.kind) {
|
||||
NotificationActionKind.Open -> {
|
||||
val pendingIntent = sbn.notification.contentIntent
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no open action",
|
||||
)
|
||||
runCatching {
|
||||
pendingIntent.send()
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "open failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Dismiss -> {
|
||||
runCatching {
|
||||
cancelNotification(sbn.key)
|
||||
DeviceNotificationStore.remove(sbn.key)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "dismiss failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Reply -> {
|
||||
val replyText = request.replyText?.trim().orEmpty()
|
||||
if (replyText.isEmpty()) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
val action =
|
||||
sbn.notification.actions
|
||||
?.firstOrNull { candidate ->
|
||||
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
|
||||
}
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no reply action",
|
||||
)
|
||||
val remoteInputs = action.remoteInputs ?: emptyArray()
|
||||
val fillInIntent = Intent()
|
||||
val replyBundle = android.os.Bundle()
|
||||
for (remoteInput in remoteInputs) {
|
||||
replyBundle.putCharSequence(remoteInput.resultKey, replyText)
|
||||
}
|
||||
RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, replyBundle)
|
||||
runCatching {
|
||||
action.actionIntent.send(this, 0, fillInIntent)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "reply failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
val smsAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
val debugBuild: Boolean,
|
||||
)
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
enum class NodeCapabilityAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
|
||||
data class NodeCapabilitySpec(
|
||||
val name: String,
|
||||
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
|
||||
)
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
@@ -24,6 +56,39 @@ data class InvokeCommandSpec(
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val capabilityManifest: List<NodeCapabilitySpec> =
|
||||
listOf(
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Camera.rawValue,
|
||||
availability = NodeCapabilityAvailability.CameraEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Sms.rawValue,
|
||||
availability = NodeCapabilityAvailability.SmsAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.VoiceWake.rawValue,
|
||||
availability = NodeCapabilityAvailability.VoiceWakeEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Location.rawValue,
|
||||
availability = NodeCapabilityAvailability.LocationEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
@@ -62,6 +127,14 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSystemCommand.Notify.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.List.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
@@ -82,9 +155,41 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Permissions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.Actions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawPhotosCommand.Latest.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Search.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Add.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCalendarCommand.Events.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCalendarCommand.Add.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawMotionCommand.Activity.rawValue,
|
||||
availability = InvokeCommandAvailability.MotionActivityAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawMotionCommand.Pedometer.rawValue,
|
||||
availability = InvokeCommandAvailability.MotionPedometerAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
@@ -104,20 +209,32 @@ object InvokeCommandRegistry {
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> {
|
||||
return capabilityManifest
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
NodeCapabilityAvailability.Always -> true
|
||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
@@ -16,6 +21,11 @@ class InvokeDispatcher(
|
||||
private val locationHandler: LocationHandler,
|
||||
private val deviceHandler: DeviceHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val systemHandler: SystemHandler,
|
||||
private val photosHandler: PhotosHandler,
|
||||
private val contactsHandler: ContactsHandler,
|
||||
private val calendarHandler: CalendarHandler,
|
||||
private val motionHandler: MotionHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -26,8 +36,11 @@ class InvokeDispatcher(
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val spec =
|
||||
@@ -112,6 +125,7 @@ class InvokeDispatcher(
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
OpenClawCameraCommand.List.rawValue -> cameraHandler.handleList(paramsJson)
|
||||
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
|
||||
OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson)
|
||||
|
||||
@@ -121,9 +135,30 @@ class InvokeDispatcher(
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleNotificationsActions(paramsJson)
|
||||
|
||||
// System command
|
||||
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
|
||||
|
||||
// Photos command
|
||||
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
|
||||
|
||||
// Contacts command
|
||||
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
|
||||
OpenClawContactsCommand.Add.rawValue -> contactsHandler.handleContactsAdd(paramsJson)
|
||||
|
||||
// Calendar command
|
||||
OpenClawCalendarCommand.Events.rawValue -> calendarHandler.handleCalendarEvents(paramsJson)
|
||||
OpenClawCalendarCommand.Add.rawValue -> calendarHandler.handleCalendarAdd(paramsJson)
|
||||
|
||||
// Motion command
|
||||
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
|
||||
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
@@ -145,17 +180,30 @@ class InvokeDispatcher(
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!readyOnFirstCheck) {
|
||||
if (!refreshNodeCanvasCapability()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
@@ -194,6 +242,24 @@ class InvokeDispatcher(
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.MotionActivityAvailable ->
|
||||
if (motionActivityAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: accelerometer not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.MotionPedometerAvailable ->
|
||||
if (motionPedometerAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PEDOMETER_UNAVAILABLE",
|
||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val ACCELEROMETER_SAMPLE_TARGET = 20
|
||||
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
|
||||
|
||||
internal data class MotionActivityRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class MotionPedometerRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
)
|
||||
|
||||
internal data class MotionActivityRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val confidence: String,
|
||||
val isWalking: Boolean,
|
||||
val isRunning: Boolean,
|
||||
val isCycling: Boolean,
|
||||
val isAutomotive: Boolean,
|
||||
val isStationary: Boolean,
|
||||
val isUnknown: Boolean,
|
||||
)
|
||||
|
||||
internal data class PedometerRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val steps: Int?,
|
||||
val distanceMeters: Double?,
|
||||
val floorsAscended: Int?,
|
||||
val floorsDescended: Int?,
|
||||
)
|
||||
|
||||
internal interface MotionDataSource {
|
||||
fun isActivityAvailable(context: Context): Boolean
|
||||
|
||||
fun isPedometerAvailable(context: Context): Boolean
|
||||
|
||||
fun isAvailable(context: Context): Boolean = isActivityAvailable(context) || isPedometerAvailable(context)
|
||||
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
|
||||
|
||||
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
|
||||
}
|
||||
|
||||
private object SystemMotionDataSource : MotionDataSource {
|
||||
override fun isActivityAvailable(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
|
||||
}
|
||||
|
||||
override fun isPedometerAvailable(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 29) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
|
||||
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
|
||||
|
||||
val sample = readAccelerometerSample(sensorManager, accelerometer)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
|
||||
val end = Instant.now()
|
||||
val start = end.minusSeconds(2)
|
||||
val classification = classifyActivity(sample.averageDelta)
|
||||
return MotionActivityRecord(
|
||||
startISO = start.toString(),
|
||||
endISO = end.toString(),
|
||||
confidence = classifyConfidence(sample.samples, sample.averageDelta),
|
||||
isWalking = classification == "walking",
|
||||
isRunning = classification == "running",
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = classification == "stationary",
|
||||
isUnknown = classification == "unknown",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
|
||||
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
|
||||
|
||||
val steps = readStepCounter(sensorManager, stepCounter)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
|
||||
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
return PedometerRecord(
|
||||
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
|
||||
endISO = Instant.now().toString(),
|
||||
steps = steps,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
)
|
||||
}
|
||||
|
||||
private data class AccelerometerSample(
|
||||
val samples: Int,
|
||||
val averageDelta: Double,
|
||||
)
|
||||
|
||||
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
|
||||
val sample =
|
||||
withTimeoutOrNull(1200L) {
|
||||
suspendCancellableCoroutine<Float?> { cont ->
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (resumed) return
|
||||
val value = event?.values?.firstOrNull()
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(value)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
sensorManager.unregisterListener(listener)
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample?.toInt()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private suspend fun readAccelerometerSample(
|
||||
sensorManager: SensorManager,
|
||||
sensor: Sensor,
|
||||
): AccelerometerSample? {
|
||||
val sample =
|
||||
withTimeoutOrNull(ACCELEROMETER_SAMPLE_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
|
||||
var count = 0
|
||||
var sumDelta = 0.0
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
val values = event?.values ?: return
|
||||
if (values.size < 3) return
|
||||
val magnitude =
|
||||
sqrt(
|
||||
values[0] * values[0] +
|
||||
values[1] * values[1] +
|
||||
values[2] * values[2],
|
||||
).toDouble()
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = if (count == 0) 0.0 else sumDelta / count,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun classifyActivity(averageDelta: Double): String {
|
||||
return when {
|
||||
averageDelta <= 0.55 -> "stationary"
|
||||
averageDelta <= 1.80 -> "walking"
|
||||
else -> "running"
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
|
||||
if (samples < 6) return "low"
|
||||
if (samples >= 14 && averageDelta > 0.4) return "high"
|
||||
return "medium"
|
||||
}
|
||||
}
|
||||
|
||||
class MotionHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: MotionDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
|
||||
|
||||
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseActivityRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val activity = dataSource.activity(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"activities",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(activity.startISO))
|
||||
put("endISO", JsonPrimitive(activity.endISO))
|
||||
put("confidence", JsonPrimitive(activity.confidence))
|
||||
put("isWalking", JsonPrimitive(activity.isWalking))
|
||||
put("isRunning", JsonPrimitive(activity.isRunning))
|
||||
put("isCycling", JsonPrimitive(activity.isCycling))
|
||||
put("isAutomotive", JsonPrimitive(activity.isAutomotive))
|
||||
put("isStationary", JsonPrimitive(activity.isStationary))
|
||||
put("isUnknown", JsonPrimitive(activity.isUnknown))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "motion activity failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parsePedometerRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val payload = dataSource.pedometer(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(payload.startISO))
|
||||
put("endISO", JsonPrimitive(payload.endISO))
|
||||
payload.steps?.let { put("steps", JsonPrimitive(it)) }
|
||||
payload.distanceMeters?.let { put("distanceMeters", JsonPrimitive(it)) }
|
||||
payload.floorsAscended?.let { put("floorsAscended", JsonPrimitive(it)) }
|
||||
payload.floorsDescended?.let { put("floorsDescended", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "pedometer query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
|
||||
|
||||
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
|
||||
|
||||
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
|
||||
|
||||
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionActivityRequest(startISO = null, endISO = null, limit = 200)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
|
||||
return MotionActivityRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePedometerRequest(paramsJson: String?): MotionPedometerRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionPedometerRequest(startISO = null, endISO = null)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return MotionPedometerRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
|
||||
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: MotionDataSource,
|
||||
): MotionHandler = MotionHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,20 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.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
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
@@ -29,6 +34,10 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
|
||||
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
return DeviceNotificationListenerService.executeAction(context, request)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
@@ -38,11 +47,80 @@ class NotificationsHandler private constructor(
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = readSnapshotWithRebind()
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
readSnapshotWithRebind()
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val key =
|
||||
readString(params, "key")
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: key required",
|
||||
)
|
||||
val actionRaw =
|
||||
readString(params, "action")?.lowercase()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
|
||||
)
|
||||
val action =
|
||||
when (actionRaw) {
|
||||
"open" -> NotificationActionKind.Open
|
||||
"dismiss" -> NotificationActionKind.Dismiss
|
||||
"reply" -> NotificationActionKind.Reply
|
||||
else ->
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action must be open|dismiss|reply",
|
||||
)
|
||||
}
|
||||
val replyText = readString(params, "replyText")
|
||||
if (action == NotificationActionKind.Reply && replyText.isNullOrBlank()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
|
||||
val result =
|
||||
stateProvider.executeAction(
|
||||
appContext,
|
||||
NotificationActionRequest(
|
||||
key = key,
|
||||
kind = action,
|
||||
replyText = replyText,
|
||||
),
|
||||
)
|
||||
if (!result.ok) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = result.code ?: "UNAVAILABLE",
|
||||
message = result.message ?: "notification action failed",
|
||||
)
|
||||
}
|
||||
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("ok", JsonPrimitive(true))
|
||||
put("key", JsonPrimitive(key))
|
||||
put("action", JsonPrimitive(actionRaw))
|
||||
}.toString()
|
||||
return GatewaySession.InvokeResult.ok(payload)
|
||||
}
|
||||
|
||||
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
@@ -72,6 +150,21 @@ class NotificationsHandler private constructor(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readString(params: JsonObject, key: String): String? =
|
||||
(params[key] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.Instant
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_PHOTOS_LIMIT = 1
|
||||
private const val DEFAULT_PHOTOS_MAX_WIDTH = 1600
|
||||
private const val DEFAULT_PHOTOS_QUALITY = 0.85
|
||||
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
|
||||
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
|
||||
|
||||
internal data class PhotosLatestRequest(
|
||||
val limit: Int,
|
||||
val maxWidth: Int,
|
||||
val quality: Double,
|
||||
)
|
||||
|
||||
internal data class EncodedPhotoPayload(
|
||||
val format: String,
|
||||
val base64: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val createdAt: String?,
|
||||
)
|
||||
|
||||
internal interface PhotosDataSource {
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload>
|
||||
}
|
||||
|
||||
private object SystemPhotosDataSource : PhotosDataSource {
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
val permission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
return ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> {
|
||||
val resolver = context.contentResolver
|
||||
val rows = queryLatestRows(resolver, request.limit)
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
|
||||
var remainingBudget = MAX_TOTAL_BASE64_CHARS
|
||||
val out = mutableListOf<EncodedPhotoPayload>()
|
||||
for (row in rows) {
|
||||
if (remainingBudget <= 0) break
|
||||
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private data class PhotoRow(
|
||||
val uri: Uri,
|
||||
val createdAtMs: Long?,
|
||||
)
|
||||
|
||||
private data class EncodedJpeg(
|
||||
val base64: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
private fun queryLatestRows(resolver: ContentResolver, limit: Int): List<PhotoRow> {
|
||||
val projection =
|
||||
arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
)
|
||||
val sortOrder =
|
||||
"${MediaStore.Images.Media.DATE_TAKEN} DESC, ${MediaStore.Images.Media.DATE_ADDED} DESC"
|
||||
val args =
|
||||
Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
|
||||
}
|
||||
|
||||
resolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
args,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
|
||||
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
|
||||
val rows = mutableListOf<PhotoRow>()
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idIndex)
|
||||
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
|
||||
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
|
||||
rows +=
|
||||
PhotoRow(
|
||||
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
|
||||
createdAtMs = takenMs ?: addedMs,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeScaledBitmap(
|
||||
resolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxWidth: Int,
|
||||
): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, bounds)
|
||||
}
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val inSampleSize = computeInSampleSize(bounds.outWidth, maxWidth)
|
||||
val decodeOptions = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize }
|
||||
val decoded =
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, decodeOptions)
|
||||
} ?: return null
|
||||
|
||||
if (decoded.width <= maxWidth) return decoded
|
||||
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
|
||||
return Bitmap.createScaledBitmap(decoded, maxWidth, targetHeight, true)
|
||||
}
|
||||
|
||||
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
|
||||
var sample = 1
|
||||
var candidate = width
|
||||
while (candidate > maxWidth && sample < 64) {
|
||||
sample *= 2
|
||||
candidate = width / sample
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun encodeJpegUnderBudget(
|
||||
bitmap: Bitmap,
|
||||
quality: Double,
|
||||
maxBase64Chars: Int,
|
||||
): EncodedJpeg? {
|
||||
var working = bitmap
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
working = Bitmap.createScaledBitmap(working, nextWidth, nextHeight, true)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class PhotosHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: PhotosDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
|
||||
|
||||
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_PERMISSION_REQUIRED",
|
||||
message = "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val photos = dataSource.latest(appContext, request)
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"photos",
|
||||
buildJsonArray {
|
||||
photos.forEach { photo ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("format", JsonPrimitive(photo.format))
|
||||
put("base64", JsonPrimitive(photo.base64))
|
||||
put("width", JsonPrimitive(photo.width))
|
||||
put("height", JsonPrimitive(photo.height))
|
||||
photo.createdAt?.let { put("createdAt", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_UNAVAILABLE",
|
||||
message = "PHOTOS_UNAVAILABLE: ${err.message ?: "photo fetch failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRequest(paramsJson: String?): PhotosLatestRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return PhotosLatestRequest(
|
||||
limit = DEFAULT_PHOTOS_LIMIT,
|
||||
maxWidth = DEFAULT_PHOTOS_MAX_WIDTH,
|
||||
quality = DEFAULT_PHOTOS_QUALITY,
|
||||
)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val limitRaw = (params["limit"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
|
||||
|
||||
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
|
||||
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
|
||||
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
|
||||
return PhotosLatestRequest(limit = limit, maxWidth = maxWidth, quality = quality)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: PhotosDataSource,
|
||||
): PhotosHandler = PhotosHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -35,12 +39,13 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
val screenIndex = parseScreenIndex(paramsJson)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val format = parseString(paramsJson, key = "format")
|
||||
val screenIndex = parseScreenIndex(params)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val format = parseString(params, key = "format")
|
||||
if (format != null && format.lowercase() != "mp4") {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
@@ -141,55 +146,38 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFps(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
|
||||
}
|
||||
|
||||
private fun parseString(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
if (!tail.startsWith('\"')) return null
|
||||
val rest = tail.drop(1)
|
||||
val end = rest.indexOf('\"')
|
||||
if (end < 0) return null
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
|
||||
|
||||
internal data class SystemNotifyRequest(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val sound: String?,
|
||||
val priority: String?,
|
||||
)
|
||||
|
||||
internal interface SystemNotificationPoster {
|
||||
fun isAuthorized(): Boolean
|
||||
|
||||
fun post(request: SystemNotifyRequest)
|
||||
}
|
||||
|
||||
private class AndroidSystemNotificationPoster(
|
||||
private val appContext: Context,
|
||||
) : SystemNotificationPoster {
|
||||
override fun isAuthorized(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val granted =
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) return false
|
||||
}
|
||||
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
val channelId = ensureChannel(request.priority)
|
||||
val silent = isSilentSound(request.sound)
|
||||
val notification =
|
||||
NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(request.title)
|
||||
.setContentText(request.body)
|
||||
.setPriority(compatPriority(request.priority))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(silent)
|
||||
.build()
|
||||
NotificationManagerCompat.from(appContext).notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), notification)
|
||||
}
|
||||
|
||||
private fun ensureChannel(priority: String?): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return NOTIFICATION_CHANNEL_BASE_ID
|
||||
}
|
||||
val normalizedPriority = priority.orEmpty().trim().lowercase()
|
||||
val (suffix, importance, name) =
|
||||
when (normalizedPriority) {
|
||||
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
|
||||
"timesensitive" -> Triple("timesensitive", NotificationManager.IMPORTANCE_HIGH, "OpenClaw Time Sensitive")
|
||||
else -> Triple("active", NotificationManager.IMPORTANCE_DEFAULT, "OpenClaw Active")
|
||||
}
|
||||
val channelId = "$NOTIFICATION_CHANNEL_BASE_ID.$suffix"
|
||||
val manager = appContext.getSystemService(NotificationManager::class.java)
|
||||
val existing = manager.getNotificationChannel(channelId)
|
||||
if (existing == null) {
|
||||
manager.createNotificationChannel(NotificationChannel(channelId, name, importance))
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun compatPriority(priority: String?): Int {
|
||||
return when (priority.orEmpty().trim().lowercase()) {
|
||||
"passive" -> NotificationCompat.PRIORITY_LOW
|
||||
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
|
||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSilentSound(sound: String?): Boolean {
|
||||
val normalized = sound?.trim()?.lowercase() ?: return false
|
||||
return normalized in setOf("none", "silent", "off", "false", "0")
|
||||
}
|
||||
}
|
||||
|
||||
class SystemHandler private constructor(
|
||||
private val poster: SystemNotificationPoster,
|
||||
) {
|
||||
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
|
||||
|
||||
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val params =
|
||||
parseNotifyRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object with title/body",
|
||||
)
|
||||
if (params.title.isEmpty() && params.body.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: empty notification",
|
||||
)
|
||||
}
|
||||
if (!poster.isAuthorized()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NOT_AUTHORIZED",
|
||||
message = "NOT_AUTHORIZED: notifications",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
poster.post(params)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "UNAVAILABLE",
|
||||
message = "NOTIFICATION_FAILED: ${err.message ?: "notification post failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
|
||||
val params = parseParamsObject(paramsJson) ?: return null
|
||||
val rawTitle =
|
||||
(params["title"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?: return null
|
||||
val rawBody =
|
||||
(params["body"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?: return null
|
||||
val sound = (params["sound"] as? JsonPrimitive)?.contentOrNull
|
||||
val priority = (params["priority"] as? JsonPrimitive)?.contentOrNull
|
||||
return SystemNotifyRequest(
|
||||
title = rawTitle.trim(),
|
||||
body = rawBody.trim(),
|
||||
sound = sound?.trim()?.ifEmpty { null },
|
||||
priority = priority?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Device("device"),
|
||||
Notifications("notifications"),
|
||||
System("system"),
|
||||
AppUpdate("appUpdate"),
|
||||
Photos("photos"),
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -35,6 +42,7 @@ enum class OpenClawCanvasA2UICommand(val rawValue: String) {
|
||||
}
|
||||
|
||||
enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
List("camera.list"),
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
@@ -74,6 +82,8 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
Status("device.status"),
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
@@ -83,9 +93,58 @@ enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
Actions("notifications.actions"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawSystemCommand(val rawValue: String) {
|
||||
Notify("system.notify"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "system."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawPhotosCommand(val rawValue: String) {
|
||||
Latest("photos.latest"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "photos."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawContactsCommand(val rawValue: String) {
|
||||
Search("contacts.search"),
|
||||
Add("contacts.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "contacts."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCalendarCommand(val rawValue: String) {
|
||||
Events("calendar.events"),
|
||||
Add("calendar.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "calendar."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawMotionCommand(val rawValue: String) {
|
||||
Activity("motion.activity"),
|
||||
Pedometer("motion.pedometer"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ package ai.openclaw.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -55,6 +60,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -74,9 +80,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.R
|
||||
import ai.openclaw.android.node.DeviceNotificationListenerService
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
@@ -92,6 +102,24 @@ private enum class GatewayInputMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
private enum class PermissionToggle {
|
||||
Discovery,
|
||||
Location,
|
||||
Notifications,
|
||||
Microphone,
|
||||
Camera,
|
||||
Photos,
|
||||
Contacts,
|
||||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
NotificationListener,
|
||||
AppUpdates,
|
||||
}
|
||||
|
||||
private val onboardingBackgroundGradient =
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
@@ -204,53 +232,245 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var gatewayError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var enableDiscovery by rememberSaveable { mutableStateOf(true) }
|
||||
var enableNotifications by rememberSaveable { mutableStateOf(true) }
|
||||
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
||||
var enableSms by rememberSaveable { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
|
||||
val selectedPermissions =
|
||||
remember(
|
||||
context,
|
||||
enableDiscovery,
|
||||
enableNotifications,
|
||||
enableMicrophone,
|
||||
enableCamera,
|
||||
enableSms,
|
||||
smsAvailable,
|
||||
) {
|
||||
val requested = mutableListOf<String>()
|
||||
if (enableDiscovery) {
|
||||
requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
}
|
||||
if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS
|
||||
if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO
|
||||
if (enableCamera) requested += Manifest.permission.CAMERA
|
||||
if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS
|
||||
requested.filterNot { isPermissionGranted(context, it) }
|
||||
val motionAvailable =
|
||||
remember(context) {
|
||||
hasMotionCapabilities(context)
|
||||
}
|
||||
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
|
||||
val notificationsPermissionRequired = Build.VERSION.SDK_INT >= 33
|
||||
val discoveryPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES
|
||||
} else {
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
}
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
var enableDiscovery by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, discoveryPermission))
|
||||
}
|
||||
var enableLocation by rememberSaveable { mutableStateOf(false) }
|
||||
var enableNotifications by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
!notificationsPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
|
||||
)
|
||||
}
|
||||
var enableNotificationListener by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isNotificationListenerEnabled(context))
|
||||
}
|
||||
var enableAppUpdates by
|
||||
rememberSaveable {
|
||||
mutableStateOf(canInstallUnknownApps(context))
|
||||
}
|
||||
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
||||
var enablePhotos by rememberSaveable { mutableStateOf(false) }
|
||||
var enableContacts by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCalendar by rememberSaveable { mutableStateOf(false) }
|
||||
var enableMotion by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
motionAvailable &&
|
||||
(!motionPermissionRequired || isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)),
|
||||
)
|
||||
}
|
||||
var enableSms by
|
||||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
|
||||
|
||||
fun setPermissionToggleEnabled(toggle: PermissionToggle, enabled: Boolean) {
|
||||
when (toggle) {
|
||||
PermissionToggle.Discovery -> enableDiscovery = enabled
|
||||
PermissionToggle.Location -> enableLocation = enabled
|
||||
PermissionToggle.Notifications -> enableNotifications = enabled
|
||||
PermissionToggle.Microphone -> enableMicrophone = enabled
|
||||
PermissionToggle.Camera -> enableCamera = enabled
|
||||
PermissionToggle.Photos -> enablePhotos = enabled
|
||||
PermissionToggle.Contacts -> enableContacts = enabled
|
||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
}
|
||||
}
|
||||
|
||||
fun isPermissionToggleGranted(toggle: PermissionToggle): Boolean =
|
||||
when (toggle) {
|
||||
PermissionToggle.Discovery -> isPermissionGranted(context, discoveryPermission)
|
||||
PermissionToggle.Location ->
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
PermissionToggle.Notifications ->
|
||||
!notificationsPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
PermissionToggle.Microphone -> isPermissionGranted(context, Manifest.permission.RECORD_AUDIO)
|
||||
PermissionToggle.Camera -> isPermissionGranted(context, Manifest.permission.CAMERA)
|
||||
PermissionToggle.Photos -> isPermissionGranted(context, photosPermission)
|
||||
PermissionToggle.Contacts ->
|
||||
isPermissionGranted(context, Manifest.permission.READ_CONTACTS) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)
|
||||
PermissionToggle.Calendar ->
|
||||
isPermissionGranted(context, Manifest.permission.READ_CALENDAR) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CALENDAR)
|
||||
PermissionToggle.Motion ->
|
||||
!motionAvailable ||
|
||||
!motionPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
|
||||
SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val enabledPermissionSummary =
|
||||
remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) {
|
||||
remember(
|
||||
enableDiscovery,
|
||||
enableLocation,
|
||||
enableNotifications,
|
||||
enableNotificationListener,
|
||||
enableAppUpdates,
|
||||
enableMicrophone,
|
||||
enableCamera,
|
||||
enablePhotos,
|
||||
enableContacts,
|
||||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
smsAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
val enabled = mutableListOf<String>()
|
||||
if (enableDiscovery) enabled += "Gateway discovery"
|
||||
if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications"
|
||||
if (enableLocation) enabled += "Location"
|
||||
if (enableNotifications) enabled += "Notifications"
|
||||
if (enableNotificationListener) enabled += "Notification listener"
|
||||
if (enableAppUpdates) enabled += "App updates"
|
||||
if (enableMicrophone) enabled += "Microphone"
|
||||
if (enableCamera) enabled += "Camera"
|
||||
if (enablePhotos) enabled += "Photos"
|
||||
if (enableContacts) enabled += "Contacts"
|
||||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
step = OnboardingStep.FinalCheck
|
||||
val proceedFromPermissions: () -> Unit = proceed@{
|
||||
var openedSpecialSetup = false
|
||||
if (enableNotificationListener && !isNotificationListenerEnabled(context)) {
|
||||
openNotificationListenerSettings(context)
|
||||
openedSpecialSetup = true
|
||||
}
|
||||
if (enableAppUpdates && !canInstallUnknownApps(context)) {
|
||||
openUnknownAppSourcesSettings(context)
|
||||
openedSpecialSetup = true
|
||||
}
|
||||
if (openedSpecialSetup) {
|
||||
return@proceed
|
||||
}
|
||||
step = OnboardingStep.FinalCheck
|
||||
}
|
||||
|
||||
val togglePermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
val pendingToggle = pendingPermissionToggle ?: return@rememberLauncherForActivityResult
|
||||
setPermissionToggleEnabled(pendingToggle, isPermissionToggleGranted(pendingToggle))
|
||||
pendingPermissionToggle = null
|
||||
}
|
||||
|
||||
val requestPermissionToggle: (PermissionToggle, Boolean, List<String>) -> Unit =
|
||||
request@{ toggle, enabled, permissions ->
|
||||
if (!enabled) {
|
||||
setPermissionToggleEnabled(toggle, false)
|
||||
return@request
|
||||
}
|
||||
if (isPermissionToggleGranted(toggle)) {
|
||||
setPermissionToggleEnabled(toggle, true)
|
||||
return@request
|
||||
}
|
||||
val missing = permissions.distinct().filterNot { isPermissionGranted(context, it) }
|
||||
if (missing.isEmpty()) {
|
||||
setPermissionToggleEnabled(toggle, isPermissionToggleGranted(toggle))
|
||||
return@request
|
||||
}
|
||||
pendingPermissionToggle = toggle
|
||||
togglePermissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val requestSpecialAccessToggle: (SpecialAccessToggle, Boolean) -> Unit =
|
||||
request@{ toggle, enabled ->
|
||||
if (!enabled) {
|
||||
setSpecialAccessToggleEnabled(toggle, false)
|
||||
pendingSpecialAccessToggle = null
|
||||
return@request
|
||||
}
|
||||
val grantedNow =
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
|
||||
SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
|
||||
}
|
||||
if (grantedNow) {
|
||||
setSpecialAccessToggleEnabled(toggle, true)
|
||||
pendingSpecialAccessToggle = null
|
||||
return@request
|
||||
}
|
||||
pendingSpecialAccessToggle = toggle
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
|
||||
SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, context, pendingSpecialAccessToggle) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_RESUME) {
|
||||
return@LifecycleEventObserver
|
||||
}
|
||||
when (pendingSpecialAccessToggle) {
|
||||
SpecialAccessToggle.NotificationListener -> {
|
||||
setSpecialAccessToggleEnabled(
|
||||
SpecialAccessToggle.NotificationListener,
|
||||
isNotificationListenerEnabled(context),
|
||||
)
|
||||
pendingSpecialAccessToggle = null
|
||||
}
|
||||
SpecialAccessToggle.AppUpdates -> {
|
||||
setSpecialAccessToggleEnabled(
|
||||
SpecialAccessToggle.AppUpdates,
|
||||
canInstallUnknownApps(context),
|
||||
)
|
||||
pendingSpecialAccessToggle = null
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val qrScanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
@@ -382,17 +602,120 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
OnboardingStep.Permissions ->
|
||||
PermissionsStep(
|
||||
enableDiscovery = enableDiscovery,
|
||||
enableLocation = enableLocation,
|
||||
enableNotifications = enableNotifications,
|
||||
enableNotificationListener = enableNotificationListener,
|
||||
enableAppUpdates = enableAppUpdates,
|
||||
enableMicrophone = enableMicrophone,
|
||||
enableCamera = enableCamera,
|
||||
enablePhotos = enablePhotos,
|
||||
enableContacts = enableContacts,
|
||||
enableCalendar = enableCalendar,
|
||||
enableMotion = enableMotion,
|
||||
motionAvailable = motionAvailable,
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
context = context,
|
||||
onDiscoveryChange = { enableDiscovery = it },
|
||||
onNotificationsChange = { enableNotifications = it },
|
||||
onMicrophoneChange = { enableMicrophone = it },
|
||||
onCameraChange = { enableCamera = it },
|
||||
onSmsChange = { enableSms = it },
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Discovery,
|
||||
checked,
|
||||
listOf(discoveryPermission),
|
||||
)
|
||||
},
|
||||
onLocationChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Location,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
},
|
||||
onNotificationsChange = { checked ->
|
||||
if (!notificationsPermissionRequired) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Notifications, checked)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Notifications,
|
||||
checked,
|
||||
listOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
)
|
||||
}
|
||||
},
|
||||
onNotificationListenerChange = { checked ->
|
||||
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
|
||||
},
|
||||
onAppUpdatesChange = { checked ->
|
||||
requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
|
||||
},
|
||||
onMicrophoneChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Microphone,
|
||||
checked,
|
||||
listOf(Manifest.permission.RECORD_AUDIO),
|
||||
)
|
||||
},
|
||||
onCameraChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Camera,
|
||||
checked,
|
||||
listOf(Manifest.permission.CAMERA),
|
||||
)
|
||||
},
|
||||
onPhotosChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Photos,
|
||||
checked,
|
||||
listOf(photosPermission),
|
||||
)
|
||||
},
|
||||
onContactsChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Contacts,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS,
|
||||
),
|
||||
)
|
||||
},
|
||||
onCalendarChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Calendar,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
),
|
||||
)
|
||||
},
|
||||
onMotionChange = { checked ->
|
||||
if (!motionAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Motion, false)
|
||||
} else if (!motionPermissionRequired) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Motion, checked)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Motion,
|
||||
checked,
|
||||
listOf(Manifest.permission.ACTIVITY_RECOGNITION),
|
||||
)
|
||||
}
|
||||
},
|
||||
onSmsChange = { checked ->
|
||||
if (!smsAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Sms, false)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Sms,
|
||||
checked,
|
||||
listOf(Manifest.permission.SEND_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
FinalStep(
|
||||
@@ -504,12 +827,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setCameraEnabled(enableCamera)
|
||||
viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off)
|
||||
if (selectedPermissions.isEmpty()) {
|
||||
step = OnboardingStep.FinalCheck
|
||||
} else {
|
||||
permissionLauncher.launch(selectedPermissions.toTypedArray())
|
||||
}
|
||||
viewModel.setLocationMode(if (enableLocation) LocationMode.WhileUsing else LocationMode.Off)
|
||||
proceedFromPermissions()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1014,19 +1333,61 @@ private fun InlineDivider() {
|
||||
@Composable
|
||||
private fun PermissionsStep(
|
||||
enableDiscovery: Boolean,
|
||||
enableLocation: Boolean,
|
||||
enableNotifications: Boolean,
|
||||
enableNotificationListener: Boolean,
|
||||
enableAppUpdates: Boolean,
|
||||
enableMicrophone: Boolean,
|
||||
enableCamera: Boolean,
|
||||
enablePhotos: Boolean,
|
||||
enableContacts: Boolean,
|
||||
enableCalendar: Boolean,
|
||||
enableMotion: Boolean,
|
||||
motionAvailable: Boolean,
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
onNotificationsChange: (Boolean) -> Unit,
|
||||
onNotificationListenerChange: (Boolean) -> Unit,
|
||||
onAppUpdatesChange: (Boolean) -> Unit,
|
||||
onMicrophoneChange: (Boolean) -> Unit,
|
||||
onCameraChange: (Boolean) -> Unit,
|
||||
onPhotosChange: (Boolean) -> Unit,
|
||||
onContactsChange: (Boolean) -> Unit,
|
||||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
val contactsGranted =
|
||||
isPermissionGranted(context, Manifest.permission.READ_CONTACTS) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)
|
||||
val calendarGranted =
|
||||
isPermissionGranted(context, Manifest.permission.READ_CALENDAR) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CALENDAR)
|
||||
val motionGranted =
|
||||
if (!motionAvailable) {
|
||||
false
|
||||
} else if (!motionPermissionRequired) {
|
||||
true
|
||||
} else {
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
val notificationListenerGranted = isNotificationListenerEnabled(context)
|
||||
val appUpdatesGranted = canInstallUnknownApps(context)
|
||||
|
||||
StepShell(title = "Permissions") {
|
||||
Text(
|
||||
"Enable only what you need now. You can change everything later in Settings.",
|
||||
@@ -1041,16 +1402,40 @@ private fun PermissionsStep(
|
||||
onCheckedChange = onDiscoveryChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Location",
|
||||
subtitle = "location.get (while app is open unless set to Always later)",
|
||||
checked = enableLocation,
|
||||
granted = locationGranted,
|
||||
onCheckedChange = onLocationChange,
|
||||
)
|
||||
InlineDivider()
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
PermissionToggleRow(
|
||||
title = "Notifications",
|
||||
subtitle = "Foreground service + alerts",
|
||||
subtitle = "system.notify and foreground alerts",
|
||||
checked = enableNotifications,
|
||||
granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
|
||||
onCheckedChange = onNotificationsChange,
|
||||
)
|
||||
InlineDivider()
|
||||
}
|
||||
PermissionToggleRow(
|
||||
title = "Notification listener",
|
||||
subtitle = "notifications.list and notifications.actions (opens Android Settings)",
|
||||
checked = enableNotificationListener,
|
||||
granted = notificationListenerGranted,
|
||||
onCheckedChange = onNotificationListenerChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "App updates",
|
||||
subtitle = "app.update install confirmation (opens Android Settings)",
|
||||
checked = enableAppUpdates,
|
||||
granted = appUpdatesGranted,
|
||||
onCheckedChange = onAppUpdatesChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Voice tab transcription",
|
||||
@@ -1066,6 +1451,40 @@ private fun PermissionsStep(
|
||||
granted = isPermissionGranted(context, Manifest.permission.CAMERA),
|
||||
onCheckedChange = onCameraChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Photos",
|
||||
subtitle = "photos.latest",
|
||||
checked = enablePhotos,
|
||||
granted = isPermissionGranted(context, photosPermission),
|
||||
onCheckedChange = onPhotosChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Contacts",
|
||||
subtitle = "contacts.search and contacts.add",
|
||||
checked = enableContacts,
|
||||
granted = contactsGranted,
|
||||
onCheckedChange = onContactsChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Calendar",
|
||||
subtitle = "calendar.events and calendar.add",
|
||||
checked = enableCalendar,
|
||||
granted = calendarGranted,
|
||||
onCheckedChange = onCalendarChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Motion",
|
||||
subtitle = "motion.activity and motion.pedometer",
|
||||
checked = enableMotion,
|
||||
granted = motionGranted,
|
||||
onCheckedChange = onMotionChange,
|
||||
enabled = motionAvailable,
|
||||
statusOverride = if (!motionAvailable) "Unavailable on this device" else null,
|
||||
)
|
||||
if (smsAvailable) {
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
@@ -1086,6 +1505,8 @@ private fun PermissionToggleRow(
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
granted: Boolean,
|
||||
enabled: Boolean = true,
|
||||
statusOverride: String? = null,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -1097,7 +1518,7 @@ private fun PermissionToggleRow(
|
||||
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
Text(
|
||||
if (granted) "Granted" else "Not granted",
|
||||
statusOverride ?: if (granted) "Granted" else "Not granted",
|
||||
style = onboardingCaption1Style,
|
||||
color = if (granted) onboardingSuccess else onboardingTextSecondary,
|
||||
)
|
||||
@@ -1105,6 +1526,7 @@ private fun PermissionToggleRow(
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
@@ -1141,8 +1563,8 @@ private fun FinalStep(
|
||||
} else {
|
||||
GuideBlock(title = "Pairing Required") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw nodes pending")
|
||||
CommandBlock("openclaw nodes approve <requestId>")
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
@@ -1207,3 +1629,50 @@ private fun Bullet(text: String) {
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 26) return true
|
||||
return context.packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
private fun openNotificationListenerSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUnknownAppSourcesSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -66,6 +68,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.node.DeviceNotificationListenerService
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
@@ -162,6 +165,91 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
|
||||
val motionAvailable = remember(context) { hasMotionCapabilities(context) }
|
||||
|
||||
var notificationsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(hasNotificationsPermission(context))
|
||||
}
|
||||
val notificationsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
notificationsPermissionGranted = granted
|
||||
}
|
||||
|
||||
var notificationListenerEnabled by
|
||||
remember {
|
||||
mutableStateOf(isNotificationListenerEnabled(context))
|
||||
}
|
||||
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val photosPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
photosPermissionGranted = granted
|
||||
}
|
||||
|
||||
var contactsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val contactsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val readOk = perms[Manifest.permission.READ_CONTACTS] == true
|
||||
val writeOk = perms[Manifest.permission.WRITE_CONTACTS] == true
|
||||
contactsPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var calendarPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val calendarPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val readOk = perms[Manifest.permission.READ_CALENDAR] == true
|
||||
val writeOk = perms[Manifest.permission.WRITE_CALENDAR] == true
|
||||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val motionPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
motionPermissionGranted = granted
|
||||
}
|
||||
|
||||
var appUpdateInstallEnabled by
|
||||
remember {
|
||||
mutableStateOf(canInstallUnknownApps(context))
|
||||
}
|
||||
|
||||
var smsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
@@ -182,6 +270,26 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
micPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
notificationsPermissionGranted = hasNotificationsPermission(context)
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
photosPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
contactsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
calendarPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
appUpdateInstallEnabled = canInstallUnknownApps(context)
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -437,6 +545,254 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Notifications
|
||||
item {
|
||||
Text(
|
||||
"NOTIFICATIONS",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val buttonLabel =
|
||||
if (notificationsPermissionGranted) {
|
||||
"Manage"
|
||||
} else {
|
||||
"Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("System Notifications", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `system.notify` and Android foreground service alerts.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `notifications.list` and `notifications.actions`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationListenerEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Data access
|
||||
item {
|
||||
Text(
|
||||
"DATA ACCESS",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `photos.latest`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Contacts Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `contacts.search` and `contacts.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (contactsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (contactsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Calendar Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `calendar.events` and `calendar.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (calendarPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (calendarPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
val motionButtonLabel =
|
||||
when {
|
||||
!motionAvailable -> "Unavailable"
|
||||
!motionPermissionRequired -> "Manage"
|
||||
motionPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Motion Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!motionAvailable) {
|
||||
"This device does not expose accelerometer or step-counter motion sensors."
|
||||
} else {
|
||||
"Required for `motion.activity` and `motion.pedometer`."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (!motionAvailable) return@Button
|
||||
if (!motionPermissionRequired || motionPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
},
|
||||
enabled = motionAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// System
|
||||
item {
|
||||
Text(
|
||||
"SYSTEM",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Install App Updates", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Enable install access for `app.update` package installs.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openUnknownAppSourcesSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (appUpdateInstallEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Location
|
||||
item {
|
||||
Text(
|
||||
@@ -603,3 +959,50 @@ private fun openAppSettings(context: Context) {
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun openNotificationListenerSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUnknownAppSourcesSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
openAppSettings(context)
|
||||
return
|
||||
}
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 26) return true
|
||||
return context.packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,14 +21,11 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -44,9 +35,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -74,9 +69,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import ai.openclaw.android.voice.VoiceConversationRole
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
@@ -85,10 +78,9 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val activity = remember(context) { context.findActivity() }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
@@ -138,33 +130,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"VOICE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
Text("Voice mode", style = mobileTitle2, color = mobileText)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
|
||||
border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
if (isConnected) "Connected" else "Offline",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = mobileCaption1,
|
||||
color = if (isConnected) mobileAccent else mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
@@ -173,15 +138,31 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Tap the mic and speak. Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = mobileTextTertiary,
|
||||
)
|
||||
Text(
|
||||
"Tap the mic to start",
|
||||
style = mobileHeadline,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,122 +178,139 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
|
||||
) {
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
micLiveTranscript!!.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mic button with input-reactive ring + speaker toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Speaker toggle
|
||||
IconButton(
|
||||
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
|
||||
// Ring size = 68dp base + up to 22dp driven by mic input level.
|
||||
// The outer Box is fixed at 90dp (max ring size) so the ring never shifts the button.
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 16.dp).size(90.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (micEnabled) {
|
||||
val ringLevel = micInputLevel.coerceIn(0f, 1f)
|
||||
val ringSize = 68.dp + (22.dp * max(ringLevel, 0.05f))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ringSize)
|
||||
.background(mobileAccent.copy(alpha = 0.12f + 0.14f * ringLevel), CircleShape),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (micEnabled) {
|
||||
viewModel.setMicEnabled(false)
|
||||
return@Button
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(60.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (micEnabled) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
micLiveTranscript!!.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
Icon(
|
||||
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MicWaveform(level = micInputLevel, active = micEnabled)
|
||||
// Invisible spacer to balance the row (same size as speaker button)
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (micEnabled) {
|
||||
viewModel.setMicEnabled(false)
|
||||
return@Button
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(86.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (micEnabled) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
// Status + labels
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
Text(
|
||||
if (micEnabled) "Tap to stop" else "Tap to speak",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
if (activity == null) {
|
||||
false
|
||||
} else {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
Text(
|
||||
if (showRationale) {
|
||||
"Microphone permission is required for voice mode."
|
||||
} else {
|
||||
"Microphone blocked. Open app settings to enable it."
|
||||
},
|
||||
style = mobileCaption1,
|
||||
color = mobileWarning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = { openAppSettings(context) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
|
||||
) {
|
||||
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
if (activity == null) {
|
||||
false
|
||||
} else {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
micStatusText,
|
||||
if (showRationale) {
|
||||
"Microphone permission is required for voice mode."
|
||||
} else {
|
||||
"Microphone blocked. Open app settings to enable it."
|
||||
},
|
||||
style = mobileCaption1,
|
||||
color = mobileTextTertiary,
|
||||
color = mobileWarning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = { openAppSettings(context) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
|
||||
) {
|
||||
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,18 +325,18 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isUser) mobileAccentSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isUser) mobileAccentSoft else Color.White,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
if (isUser) "You" else "OpenClaw",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
|
||||
color = if (isUser) mobileAccent else mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
|
||||
@@ -355,12 +353,12 @@ private fun VoiceThinkingBubble() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -389,44 +387,6 @@ private fun ThinkingDot(alpha: Float, color: Color) {
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MicWaveform(level: Float, active: Boolean) {
|
||||
val transition = rememberInfiniteTransition(label = "voiceWave")
|
||||
val phase by
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
|
||||
label = "voiceWavePhase",
|
||||
)
|
||||
|
||||
val effective = if (active) level.coerceIn(0f, 1f) else 0f
|
||||
val base = max(effective, if (active) 0.05f else 0f)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(16) { index ->
|
||||
val pulse =
|
||||
if (!active) {
|
||||
0f
|
||||
} else {
|
||||
((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
|
||||
}
|
||||
val barHeight = 6.dp + (24.dp * (base * pulse))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(5.dp)
|
||||
.height(barHeight)
|
||||
.background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,8 +40,10 @@ class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val sendToGateway: suspend (String) -> String?,
|
||||
private val speakAssistantReply: suspend (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "MicCapture"
|
||||
private const val speechMinSessionMs = 30_000L
|
||||
private const val speechCompleteSilenceMs = 1_500L
|
||||
private const val speechPossibleSilenceMs = 900L
|
||||
@@ -48,10 +51,6 @@ class MicCaptureManager(
|
||||
private const val pendingRunTimeoutMs = 45_000L
|
||||
}
|
||||
|
||||
private data class QueuedUtterance(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -79,7 +78,7 @@ class MicCaptureManager(
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
val isSending: StateFlow<Boolean> = _isSending
|
||||
|
||||
private val messageQueue = ArrayDeque<QueuedUtterance>()
|
||||
private val messageQueue = ArrayDeque<String>()
|
||||
private val sessionSegments = mutableListOf<String>()
|
||||
private var lastFinalSegment: String? = null
|
||||
private var pendingRunId: String? = null
|
||||
@@ -140,6 +139,7 @@ class MicCaptureManager(
|
||||
val finalText = parseAssistantText(payload)?.trim().orEmpty()
|
||||
if (finalText.isNotEmpty()) {
|
||||
upsertPendingAssistant(text = finalText, isStreaming = false)
|
||||
playAssistantReplyAsync(finalText)
|
||||
} else if (pendingAssistantEntryId != null) {
|
||||
updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
|
||||
}
|
||||
@@ -251,12 +251,12 @@ class MicCaptureManager(
|
||||
role = VoiceConversationRole.User,
|
||||
text = message,
|
||||
)
|
||||
messageQueue.addLast(QueuedUtterance(text = message))
|
||||
messageQueue.addLast(message)
|
||||
publishQueue()
|
||||
}
|
||||
|
||||
private fun publishQueue() {
|
||||
_queuedMessages.value = messageQueue.map { it.text }
|
||||
_queuedMessages.value = messageQueue.toList()
|
||||
}
|
||||
|
||||
private fun sendQueuedIfIdle() {
|
||||
@@ -282,7 +282,7 @@ class MicCaptureManager(
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val runId = sendToGateway(next.text)
|
||||
val runId = sendToGateway(next)
|
||||
pendingRunId = runId
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
@@ -361,15 +361,21 @@ class MicCaptureManager(
|
||||
|
||||
private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
|
||||
val current = _conversation.value
|
||||
_conversation.value =
|
||||
current.map { entry ->
|
||||
if (entry.id == id) {
|
||||
val updatedText = text ?: entry.text
|
||||
entry.copy(text = updatedText, isStreaming = isStreaming)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
if (current.isEmpty()) return
|
||||
|
||||
val targetIndex =
|
||||
when {
|
||||
current[current.lastIndex].id == id -> current.lastIndex
|
||||
else -> current.indexOfFirst { it.id == id }
|
||||
}
|
||||
if (targetIndex < 0) return
|
||||
|
||||
val entry = current[targetIndex]
|
||||
val updatedText = text ?: entry.text
|
||||
if (updatedText == entry.text && entry.isStreaming == isStreaming) return
|
||||
val updated = current.toMutableList()
|
||||
updated[targetIndex] = entry.copy(text = updatedText, isStreaming = isStreaming)
|
||||
_conversation.value = updated
|
||||
}
|
||||
|
||||
private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
|
||||
@@ -386,6 +392,18 @@ class MicCaptureManager(
|
||||
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
|
||||
}
|
||||
|
||||
private fun playAssistantReplyAsync(text: String) {
|
||||
val spoken = text.trim()
|
||||
if (spoken.isEmpty()) return
|
||||
scope.launch {
|
||||
try {
|
||||
speakAssistantReply(spoken)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "assistant speech failed: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFinalTranscript(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
|
||||
@@ -26,7 +26,9 @@ import ai.openclaw.android.normalizeMainKey
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -146,6 +148,9 @@ class TalkModeManager(
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingFinal: CompletableDeferred<Boolean>? = null
|
||||
private var chatSubscribedSessionKey: String? = null
|
||||
private var configLoaded = false
|
||||
@Volatile private var playbackEnabled = true
|
||||
private val playbackGeneration = AtomicLong(0L)
|
||||
|
||||
private var player: MediaPlayer? = null
|
||||
private var streamingSource: StreamingMediaDataSource? = null
|
||||
@@ -194,6 +199,28 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlaybackEnabled(enabled: Boolean) {
|
||||
if (playbackEnabled == enabled) return
|
||||
playbackEnabled = enabled
|
||||
if (!enabled) {
|
||||
playbackGeneration.incrementAndGet()
|
||||
stopSpeaking()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshConfig() {
|
||||
reloadConfig()
|
||||
}
|
||||
|
||||
suspend fun speakAssistantReply(text: String) {
|
||||
if (!playbackEnabled) return
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
stopSpeaking(resetInterrupt = false)
|
||||
ensureConfigLoaded()
|
||||
ensurePlaybackActive(playbackToken)
|
||||
playAssistant(text, playbackToken)
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
mainHandler.post {
|
||||
if (_isListening.value) return@post
|
||||
@@ -342,7 +369,7 @@ class TalkModeManager(
|
||||
lastTranscript = ""
|
||||
lastHeardAtMs = null
|
||||
|
||||
reloadConfig()
|
||||
ensureConfigLoaded()
|
||||
val prompt = buildPrompt(transcript)
|
||||
if (!isConnected()) {
|
||||
_statusText.value = "Gateway not connected"
|
||||
@@ -369,8 +396,15 @@ class TalkModeManager(
|
||||
return
|
||||
}
|
||||
Log.d(tag, "assistant text ok chars=${assistant.length}")
|
||||
playAssistant(assistant)
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
stopSpeaking(resetInterrupt = false)
|
||||
ensurePlaybackActive(playbackToken)
|
||||
playAssistant(assistant, playbackToken)
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) {
|
||||
Log.d(tag, "finalize speech cancelled")
|
||||
return
|
||||
}
|
||||
_statusText.value = "Talk failed: ${err.message ?: err::class.simpleName}"
|
||||
Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
@@ -487,7 +521,7 @@ class TalkModeManager(
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun playAssistant(text: String) {
|
||||
private suspend fun playAssistant(text: String, playbackToken: Long) {
|
||||
val parsed = TalkDirectiveParser.parse(text)
|
||||
if (parsed.unknownKeys.isNotEmpty()) {
|
||||
Log.w(tag, "Unknown talk directive keys: ${parsed.unknownKeys}")
|
||||
@@ -515,6 +549,7 @@ class TalkModeManager(
|
||||
modelOverrideActive = true
|
||||
}
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
|
||||
val apiKey =
|
||||
apiKey?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -541,9 +576,10 @@ class TalkModeManager(
|
||||
if (apiKey.isNullOrEmpty()) {
|
||||
Log.w(tag, "missing ELEVENLABS_API_KEY; falling back to system voice")
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
_usingFallbackTts.value = true
|
||||
_statusText.value = "Speaking (System)…"
|
||||
speakWithSystemTts(cleaned)
|
||||
speakWithSystemTts(cleaned, playbackToken)
|
||||
} else {
|
||||
_usingFallbackTts.value = false
|
||||
val ttsStarted = SystemClock.elapsedRealtime()
|
||||
@@ -564,43 +600,71 @@ class TalkModeManager(
|
||||
language = TalkModeRuntime.validatedLanguage(directive?.language),
|
||||
latencyTier = TalkModeRuntime.validatedLatencyTier(directive?.latencyTier),
|
||||
)
|
||||
streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request)
|
||||
streamAndPlay(voiceId = voiceId!!, apiKey = apiKey!!, request = request, playbackToken = playbackToken)
|
||||
Log.d(tag, "elevenlabs stream ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (isPlaybackCancelled(err, playbackToken)) {
|
||||
Log.d(tag, "assistant speech cancelled")
|
||||
return
|
||||
}
|
||||
Log.w(tag, "speak failed: ${err.message ?: err::class.simpleName}; falling back to system voice")
|
||||
try {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
_usingFallbackTts.value = true
|
||||
_statusText.value = "Speaking (System)…"
|
||||
speakWithSystemTts(cleaned)
|
||||
speakWithSystemTts(cleaned, playbackToken)
|
||||
} catch (fallbackErr: Throwable) {
|
||||
if (isPlaybackCancelled(fallbackErr, playbackToken)) {
|
||||
Log.d(tag, "assistant fallback speech cancelled")
|
||||
return
|
||||
}
|
||||
_statusText.value = "Speak failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}"
|
||||
Log.w(tag, "system voice failed: ${fallbackErr.message ?: fallbackErr::class.simpleName}")
|
||||
}
|
||||
} finally {
|
||||
_isSpeaking.value = false
|
||||
}
|
||||
|
||||
_isSpeaking.value = false
|
||||
}
|
||||
|
||||
private suspend fun streamAndPlay(voiceId: String, apiKey: String, request: ElevenLabsRequest) {
|
||||
private suspend fun streamAndPlay(
|
||||
voiceId: String,
|
||||
apiKey: String,
|
||||
request: ElevenLabsRequest,
|
||||
playbackToken: Long,
|
||||
) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
stopSpeaking(resetInterrupt = false)
|
||||
ensurePlaybackActive(playbackToken)
|
||||
|
||||
pcmStopRequested = false
|
||||
val pcmSampleRate = TalkModeRuntime.parsePcmSampleRate(request.outputFormat)
|
||||
if (pcmSampleRate != null) {
|
||||
try {
|
||||
streamAndPlayPcm(voiceId = voiceId, apiKey = apiKey, request = request, sampleRate = pcmSampleRate)
|
||||
streamAndPlayPcm(
|
||||
voiceId = voiceId,
|
||||
apiKey = apiKey,
|
||||
request = request,
|
||||
sampleRate = pcmSampleRate,
|
||||
playbackToken = playbackToken,
|
||||
)
|
||||
return
|
||||
} catch (err: Throwable) {
|
||||
if (pcmStopRequested) return
|
||||
if (isPlaybackCancelled(err, playbackToken) || pcmStopRequested) return
|
||||
Log.w(tag, "pcm playback failed; falling back to mp3: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request)
|
||||
ensurePlaybackActive(playbackToken)
|
||||
streamAndPlayMp3(voiceId = voiceId, apiKey = apiKey, request = request, playbackToken = playbackToken)
|
||||
}
|
||||
|
||||
private suspend fun streamAndPlayMp3(voiceId: String, apiKey: String, request: ElevenLabsRequest) {
|
||||
private suspend fun streamAndPlayMp3(
|
||||
voiceId: String,
|
||||
apiKey: String,
|
||||
request: ElevenLabsRequest,
|
||||
playbackToken: Long,
|
||||
) {
|
||||
val dataSource = StreamingMediaDataSource()
|
||||
streamingSource = dataSource
|
||||
|
||||
@@ -637,7 +701,7 @@ class TalkModeManager(
|
||||
val fetchJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource)
|
||||
streamTts(voiceId = voiceId, apiKey = apiKey, request = request, sink = dataSource, playbackToken = playbackToken)
|
||||
fetchError.complete(null)
|
||||
} catch (err: Throwable) {
|
||||
dataSource.fail()
|
||||
@@ -647,8 +711,11 @@ class TalkModeManager(
|
||||
|
||||
Log.d(tag, "play start")
|
||||
try {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
prepared.await()
|
||||
ensurePlaybackActive(playbackToken)
|
||||
finished.await()
|
||||
ensurePlaybackActive(playbackToken)
|
||||
fetchError.await()?.let { throw it }
|
||||
} finally {
|
||||
fetchJob.cancel()
|
||||
@@ -662,7 +729,9 @@ class TalkModeManager(
|
||||
apiKey: String,
|
||||
request: ElevenLabsRequest,
|
||||
sampleRate: Int,
|
||||
playbackToken: Long,
|
||||
) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val minBuffer =
|
||||
AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
@@ -698,20 +767,22 @@ class TalkModeManager(
|
||||
|
||||
Log.d(tag, "pcm play start sampleRate=$sampleRate bufferSize=$bufferSize")
|
||||
try {
|
||||
streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track)
|
||||
streamPcm(voiceId = voiceId, apiKey = apiKey, request = request, track = track, playbackToken = playbackToken)
|
||||
} finally {
|
||||
cleanupPcmTrack()
|
||||
}
|
||||
Log.d(tag, "pcm play done")
|
||||
}
|
||||
|
||||
private suspend fun speakWithSystemTts(text: String) {
|
||||
private suspend fun speakWithSystemTts(text: String, playbackToken: Long) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val ok = ensureSystemTts()
|
||||
if (!ok) {
|
||||
throw IllegalStateException("system TTS unavailable")
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
|
||||
val tts = systemTts ?: throw IllegalStateException("system TTS unavailable")
|
||||
val utteranceId = "talk-${UUID.randomUUID()}"
|
||||
@@ -721,6 +792,7 @@ class TalkModeManager(
|
||||
systemTtsPendingId = utteranceId
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val params = Bundle()
|
||||
tts.speak(trimmed, TextToSpeech.QUEUE_FLUSH, params, utteranceId)
|
||||
}
|
||||
@@ -731,6 +803,7 @@ class TalkModeManager(
|
||||
} catch (err: Throwable) {
|
||||
throw err
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +923,23 @@ class TalkModeManager(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ensurePlaybackActive(playbackToken: Long) {
|
||||
if (!playbackEnabled || playbackToken != playbackGeneration.get()) {
|
||||
throw CancellationException("assistant speech cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPlaybackCancelled(err: Throwable?, playbackToken: Long): Boolean {
|
||||
if (err is CancellationException) return true
|
||||
return !playbackEnabled || playbackToken != playbackGeneration.get()
|
||||
}
|
||||
|
||||
private suspend fun ensureConfigLoaded() {
|
||||
if (!configLoaded) {
|
||||
reloadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadConfig() {
|
||||
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
|
||||
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
||||
@@ -902,6 +992,7 @@ class TalkModeManager(
|
||||
} else if (selection?.normalizedPayload == true) {
|
||||
Log.d(tag, "talk config provider=elevenlabs")
|
||||
}
|
||||
configLoaded = true
|
||||
} catch (_: Throwable) {
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
defaultModelId = defaultModelIdFallback
|
||||
@@ -909,6 +1000,8 @@ class TalkModeManager(
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = emptyMap()
|
||||
defaultOutputFormat = defaultOutputFormatFallback
|
||||
// Keep config load retryable after transient fetch failures.
|
||||
configLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,8 +1015,10 @@ class TalkModeManager(
|
||||
apiKey: String,
|
||||
request: ElevenLabsRequest,
|
||||
sink: StreamingMediaDataSource,
|
||||
playbackToken: Long,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request)
|
||||
try {
|
||||
val payload = buildRequestPayload(request)
|
||||
@@ -939,8 +1034,10 @@ class TalkModeManager(
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
while (true) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
ensurePlaybackActive(playbackToken)
|
||||
sink.append(buffer.copyOf(read))
|
||||
}
|
||||
}
|
||||
@@ -956,8 +1053,10 @@ class TalkModeManager(
|
||||
apiKey: String,
|
||||
request: ElevenLabsRequest,
|
||||
track: AudioTrack,
|
||||
playbackToken: Long,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val conn = openTtsConnection(voiceId = voiceId, apiKey = apiKey, request = request)
|
||||
try {
|
||||
val payload = buildRequestPayload(request)
|
||||
@@ -972,21 +1071,21 @@ class TalkModeManager(
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
conn.inputStream.use { input ->
|
||||
while (true) {
|
||||
if (pcmStopRequested) return@withContext
|
||||
if (pcmStopRequested || isPlaybackCancelled(null, playbackToken)) return@withContext
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
var offset = 0
|
||||
while (offset < read) {
|
||||
if (pcmStopRequested) return@withContext
|
||||
if (pcmStopRequested || isPlaybackCancelled(null, playbackToken)) return@withContext
|
||||
val wrote =
|
||||
try {
|
||||
track.write(buffer, offset, read - offset)
|
||||
} catch (err: Throwable) {
|
||||
if (pcmStopRequested) return@withContext
|
||||
if (pcmStopRequested || isPlaybackCancelled(err, playbackToken)) return@withContext
|
||||
throw err
|
||||
}
|
||||
if (wrote <= 0) {
|
||||
if (pcmStopRequested) return@withContext
|
||||
if (pcmStopRequested || isPlaybackCancelled(null, playbackToken)) return@withContext
|
||||
throw IllegalStateException("AudioTrack write failed: $wrote")
|
||||
}
|
||||
offset += wrote
|
||||
|
||||
@@ -439,4 +439,128 @@ class GatewaySessionInvokeTest {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewaySessionInvokeTimeoutTest {
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(0L))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesInvokeBudgetWithinBounds() {
|
||||
assertEquals(30_000L, resolveInvokeResultAckTimeoutMs(30_000L))
|
||||
assertEquals(90_000L, resolveInvokeResultAckTimeoutMs(90_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_capsAtUpperBound() {
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesTerminalCapabilitySegment() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesWhenQueryAndFragmentPresent() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token?a=1#frag",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token?a=1#frag",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CalendarHandlerTest {
|
||||
@Test
|
||||
fun handleCalendarEvents_requiresPermission() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleCalendarEvents(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarAdd_rejectsEndBeforeStart() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = true, canWrite = true))
|
||||
|
||||
val result =
|
||||
handler.handleCalendarAdd(
|
||||
"""{"title":"Standup","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T09:00:00Z"}""",
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_INVALID", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarEvents_returnsEvents() {
|
||||
val event =
|
||||
CalendarEventRecord(
|
||||
identifier = "101",
|
||||
title = "Sprint Planning",
|
||||
startISO = "2026-02-28T10:00:00Z",
|
||||
endISO = "2026-02-28T11:00:00Z",
|
||||
isAllDay = false,
|
||||
location = "Room 1",
|
||||
calendarTitle = "Work",
|
||||
)
|
||||
val handler =
|
||||
CalendarHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCalendarDataSource(canRead = true, events = listOf(event)),
|
||||
)
|
||||
|
||||
val result = handler.handleCalendarEvents("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val events = payload.getValue("events").jsonArray
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("Sprint Planning", events.first().jsonObject.getValue("title").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarAdd_mapsNotFoundErrorCode() {
|
||||
val source =
|
||||
FakeCalendarDataSource(
|
||||
canRead = true,
|
||||
canWrite = true,
|
||||
addError = IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar"),
|
||||
)
|
||||
val handler = CalendarHandler.forTesting(appContext(), source)
|
||||
|
||||
val result =
|
||||
handler.handleCalendarAdd(
|
||||
"""{"title":"Call","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T11:00:00Z"}""",
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeCalendarDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val canWrite: Boolean = false,
|
||||
private val events: List<CalendarEventRecord> = emptyList(),
|
||||
private val addResult: CalendarEventRecord =
|
||||
CalendarEventRecord(
|
||||
identifier = "0",
|
||||
title = "Default",
|
||||
startISO = "2026-01-01T00:00:00Z",
|
||||
endISO = "2026-01-01T01:00:00Z",
|
||||
isAllDay = false,
|
||||
location = null,
|
||||
calendarTitle = null,
|
||||
),
|
||||
private val addError: Throwable? = null,
|
||||
) : CalendarDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean = canWrite
|
||||
|
||||
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = events
|
||||
|
||||
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
|
||||
addError?.let { throw it }
|
||||
return addResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CameraHandlerTest {
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_allowsZeroAndLimit() {
|
||||
assertTrue(isCameraClipWithinPayloadLimit(0L))
|
||||
assertTrue(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_rejectsNegativeAndTooLarge() {
|
||||
assertFalse(isCameraClipWithinPayloadLimit(-1L))
|
||||
assertFalse(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES + 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraClipMaxRawBytes_matchesExpectedBudget() {
|
||||
assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContactsHandlerTest {
|
||||
@Test
|
||||
fun handleContactsSearch_requiresReadPermission() {
|
||||
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleContactsSearch(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CONTACTS_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsAdd_rejectsEmptyContact() {
|
||||
val handler =
|
||||
ContactsHandler.forTesting(
|
||||
appContext(),
|
||||
FakeContactsDataSource(canRead = true, canWrite = true),
|
||||
)
|
||||
|
||||
val result = handler.handleContactsAdd("""{"givenName":" ","emails":[]}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CONTACTS_INVALID", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsSearch_returnsContacts() {
|
||||
val contact =
|
||||
ContactRecord(
|
||||
identifier = "1",
|
||||
displayName = "Ada Lovelace",
|
||||
givenName = "Ada",
|
||||
familyName = "Lovelace",
|
||||
organizationName = "Analytical Engine",
|
||||
phoneNumbers = listOf("+12025550123"),
|
||||
emails = listOf("ada@example.com"),
|
||||
)
|
||||
val handler =
|
||||
ContactsHandler.forTesting(
|
||||
appContext(),
|
||||
FakeContactsDataSource(canRead = true, searchResults = listOf(contact)),
|
||||
)
|
||||
|
||||
val result = handler.handleContactsSearch("""{"query":"ada","limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val contacts = payload.getValue("contacts").jsonArray
|
||||
assertEquals(1, contacts.size)
|
||||
assertEquals("Ada Lovelace", contacts.first().jsonObject.getValue("displayName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsAdd_returnsAddedContact() {
|
||||
val added =
|
||||
ContactRecord(
|
||||
identifier = "2",
|
||||
displayName = "Grace Hopper",
|
||||
givenName = "Grace",
|
||||
familyName = "Hopper",
|
||||
organizationName = "US Navy",
|
||||
phoneNumbers = listOf(),
|
||||
emails = listOf("grace@example.com"),
|
||||
)
|
||||
val source = FakeContactsDataSource(canRead = true, canWrite = true, addResult = added)
|
||||
val handler = ContactsHandler.forTesting(appContext(), source)
|
||||
|
||||
val result =
|
||||
handler.handleContactsAdd(
|
||||
"""{"givenName":"Grace","familyName":"Hopper","emails":["grace@example.com"]}""",
|
||||
)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val contact = payload.getValue("contact").jsonObject
|
||||
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
|
||||
assertEquals(1, source.addCalls)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeContactsDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val canWrite: Boolean = false,
|
||||
private val searchResults: List<ContactRecord> = emptyList(),
|
||||
private val addResult: ContactRecord =
|
||||
ContactRecord(
|
||||
identifier = "0",
|
||||
displayName = "Default",
|
||||
givenName = "",
|
||||
familyName = "",
|
||||
organizationName = "",
|
||||
phoneNumbers = emptyList(),
|
||||
emails = emptyList(),
|
||||
),
|
||||
) : ContactsDataSource {
|
||||
var addCalls: Int = 0
|
||||
private set
|
||||
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean = canWrite
|
||||
|
||||
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> = searchResults
|
||||
|
||||
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
|
||||
addCalls += 1
|
||||
return addResult
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,73 @@ class DeviceHandlerTest {
|
||||
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDevicePermissions_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDevicePermissions(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val permissions = payload.getValue("permissions").jsonObject
|
||||
val expected =
|
||||
listOf(
|
||||
"camera",
|
||||
"microphone",
|
||||
"location",
|
||||
"backgroundLocation",
|
||||
"sms",
|
||||
"notificationListener",
|
||||
"notifications",
|
||||
"photos",
|
||||
"contacts",
|
||||
"calendar",
|
||||
"motion",
|
||||
"screenCapture",
|
||||
)
|
||||
for (key in expected) {
|
||||
val state = permissions.getValue(key).jsonObject
|
||||
val status = state.getValue("status").jsonPrimitive.content
|
||||
assertTrue(status == "granted" || status == "denied")
|
||||
state.getValue("promptable").jsonPrimitive.boolean
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceHealth_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceHealth(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val memory = payload.getValue("memory").jsonObject
|
||||
val battery = payload.getValue("battery").jsonObject
|
||||
val power = payload.getValue("power").jsonObject
|
||||
val system = payload.getValue("system").jsonObject
|
||||
|
||||
val pressure = memory.getValue("pressure").jsonPrimitive.content
|
||||
assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown"))
|
||||
val totalRamBytes = memory.getValue("totalRamBytes").jsonPrimitive.content.toLong()
|
||||
val availableRamBytes = memory.getValue("availableRamBytes").jsonPrimitive.content.toLong()
|
||||
val usedRamBytes = memory.getValue("usedRamBytes").jsonPrimitive.content.toLong()
|
||||
assertTrue(totalRamBytes >= 0L)
|
||||
assertTrue(availableRamBytes >= 0L)
|
||||
assertTrue(usedRamBytes >= 0L)
|
||||
memory.getValue("lowMemory").jsonPrimitive.boolean
|
||||
|
||||
val batteryState = battery.getValue("state").jsonPrimitive.content
|
||||
assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full"))
|
||||
val chargingType = battery.getValue("chargingType").jsonPrimitive.content
|
||||
assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock"))
|
||||
battery["temperatureC"]?.jsonPrimitive?.double
|
||||
battery["currentMa"]?.jsonPrimitive?.double
|
||||
|
||||
power.getValue("dozeModeEnabled").jsonPrimitive.boolean
|
||||
power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
|
||||
system["securityPatchLevel"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
|
||||
@@ -1,31 +1,116 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCapabilities_respectsFeatureAvailability() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
@@ -36,21 +121,57 @@ class InvokeCommandRegistryTest {
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_onlyIncludesSupportedMotionCommands() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = false))
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_rejectsInvalidJson() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = true))
|
||||
|
||||
val result = handler.handleMotionActivity("[]")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_returnsActivityPayload() =
|
||||
runTest {
|
||||
val activity =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T10:00:00Z",
|
||||
endISO = "2026-02-28T10:00:02Z",
|
||||
confidence = "high",
|
||||
isWalking = true,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = false,
|
||||
isUnknown = false,
|
||||
)
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(hasPermission = true, activityRecord = activity),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val activities = payload.getValue("activities").jsonArray
|
||||
assertEquals(1, activities.size)
|
||||
assertEquals("high", activities.first().jsonObject.getValue("confidence").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionPedometer_mapsRangeUnsupportedError() =
|
||||
runTest {
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(
|
||||
hasPermission = true,
|
||||
pedometerError = IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: not supported"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionPedometer("""{"startISO":"2026-02-01T00:00:00Z"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
private val hasPermission: Boolean,
|
||||
private val activityAvailable: Boolean = true,
|
||||
private val pedometerAvailable: Boolean = true,
|
||||
private val activityRecord: MotionActivityRecord =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T00:00:02Z",
|
||||
confidence = "medium",
|
||||
isWalking = false,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = true,
|
||||
isUnknown = false,
|
||||
),
|
||||
private val pedometerRecord: PedometerRecord =
|
||||
PedometerRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T01:00:00Z",
|
||||
steps = 1234,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
),
|
||||
private val activityError: Throwable? = null,
|
||||
private val pedometerError: Throwable? = null,
|
||||
) : MotionDataSource {
|
||||
override fun isActivityAvailable(context: Context): Boolean = activityAvailable
|
||||
|
||||
override fun isPedometerAvailable(context: Context): Boolean = pedometerAvailable
|
||||
|
||||
override fun hasPermission(context: Context): Boolean = hasPermission
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
activityError?.let { throw it }
|
||||
return activityRecord
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
pedometerError?.let { throw it }
|
||||
return pedometerRecord
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,98 @@ class NotificationsHandlerTest {
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_executesDismissAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("ok").jsonPrimitive.boolean)
|
||||
assertEquals("n2", payload.getValue("key").jsonPrimitive.content)
|
||||
assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content)
|
||||
assertEquals("n2", provider.lastAction?.key)
|
||||
assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requiresReplyTextForReplyAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n3")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n3","action":"reply"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
assertEquals(0, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_propagatesProviderError() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n4")),
|
||||
),
|
||||
).also {
|
||||
it.actionResult =
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
}
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n5")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n5","action":"open"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
@@ -110,6 +202,13 @@ class NotificationsHandlerTest {
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActionClearablePolicy_onlyRequiresClearableForDismiss() {
|
||||
assertTrue(actionRequiresClearableNotification(NotificationActionKind.Dismiss))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Open))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Reply))
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
@@ -137,10 +236,23 @@ private class FakeNotificationsStateProvider(
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
var actionRequests: Int = 0
|
||||
private set
|
||||
var actionResult: NotificationActionResult = NotificationActionResult(ok = true)
|
||||
var lastAction: NotificationActionRequest? = null
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
|
||||
override fun executeAction(
|
||||
context: Context,
|
||||
request: NotificationActionRequest,
|
||||
): NotificationActionResult {
|
||||
actionRequests += 1
|
||||
lastAction = request
|
||||
return actionResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PhotosHandlerTest {
|
||||
@Test
|
||||
fun handlePhotosLatest_requiresPermission() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
|
||||
|
||||
val result = handler.handlePhotosLatest(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("PHOTOS_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePhotosLatest_rejectsInvalidJson() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = true))
|
||||
|
||||
val result = handler.handlePhotosLatest("[]")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePhotosLatest_returnsPayload() {
|
||||
val source =
|
||||
FakePhotosDataSource(
|
||||
hasPermission = true,
|
||||
latest = listOf(
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = "abc123",
|
||||
width = 640,
|
||||
height = 480,
|
||||
createdAt = "2026-02-28T00:00:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
val handler = PhotosHandler.forTesting(appContext(), source)
|
||||
|
||||
val result = handler.handlePhotosLatest("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val photos = payload.getValue("photos").jsonArray
|
||||
assertEquals(1, photos.size)
|
||||
val first = photos.first().jsonObject
|
||||
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
|
||||
assertEquals(640, first.getValue("width").jsonPrimitive.int)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakePhotosDataSource(
|
||||
private val hasPermission: Boolean,
|
||||
private val latest: List<EncodedPhotoPayload> = emptyList(),
|
||||
) : PhotosDataSource {
|
||||
override fun hasPermission(context: Context): Boolean = hasPermission
|
||||
|
||||
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> = latest
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SystemHandlerTest {
|
||||
@Test
|
||||
fun handleSystemNotify_rejectsUnauthorized() {
|
||||
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOT_AUTHORIZED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_rejectsEmptyNotification() {
|
||||
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":" ","body":" "}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_postsNotification() {
|
||||
val poster = FakePoster(authorized = true)
|
||||
val handler = SystemHandler.forTesting(poster = poster)
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, poster.posts)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakePoster(
|
||||
private val authorized: Boolean,
|
||||
) : SystemNotificationPoster {
|
||||
var posts: Int = 0
|
||||
private set
|
||||
|
||||
override fun isAuthorized(): Boolean = authorized
|
||||
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
posts += 1
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("location", OpenClawCapability.Location.rawValue)
|
||||
assertEquals("sms", OpenClawCapability.Sms.rawValue)
|
||||
assertEquals("device", OpenClawCapability.Device.rawValue)
|
||||
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
|
||||
assertEquals("system", OpenClawCapability.System.rawValue)
|
||||
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
|
||||
assertEquals("photos", OpenClawCapability.Photos.rawValue)
|
||||
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
||||
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
||||
assertEquals("motion", OpenClawCapability.Motion.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraCommandsUseStableStrings() {
|
||||
assertEquals("camera.list", OpenClawCameraCommand.List.rawValue)
|
||||
assertEquals("camera.snap", OpenClawCameraCommand.Snap.rawValue)
|
||||
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -39,11 +53,42 @@ class OpenClawProtocolConstantsTest {
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
assertEquals("notifications.actions", OpenClawNotificationsCommand.Actions.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceCommandsUseStableStrings() {
|
||||
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
|
||||
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemCommandsUseStableStrings() {
|
||||
assertEquals("system.notify", OpenClawSystemCommand.Notify.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photosCommandsUseStableStrings() {
|
||||
assertEquals("photos.latest", OpenClawPhotosCommand.Latest.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactsCommandsUseStableStrings() {
|
||||
assertEquals("contacts.search", OpenClawContactsCommand.Search.rawValue)
|
||||
assertEquals("contacts.add", OpenClawContactsCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarCommandsUseStableStrings() {
|
||||
assertEquals("calendar.events", OpenClawCalendarCommand.Events.rawValue)
|
||||
assertEquals("calendar.add", OpenClawCalendarCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun motionCommandsUseStableStrings() {
|
||||
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
|
||||
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260227</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260227</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260227</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260227</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260227</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -148,8 +148,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -179,8 +179,8 @@ targets:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -203,8 +203,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -237,5 +237,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.2.27</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602260</string>
|
||||
<string>202602270</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
1
changelog/fragments/pr-5080.md
Normal file
1
changelog/fragments/pr-5080.md
Normal file
@@ -0,0 +1 @@
|
||||
- Clarify block reply pipeline seen-check parameter naming for maintainability (#5080) (thanks @yassine20011)
|
||||
16
docs/assets/sponsors/convex.svg
Normal file
16
docs/assets/sponsors/convex.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="126" height="20" viewBox="0 0 126 20" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5_2)">
|
||||
<path d="M3.18483 17.4674C1.30005 15.782 0.357666 13.2908 0.357666 10.0003C0.357666 6.70977 1.31835 4.2186 3.24278 2.53321C5.16415 0.847812 7.79308 0.00350952 11.1265 0.00350952C12.5111 0.00350952 13.7341 0.103028 14.7985 0.308486C15.8629 0.510733 16.8815 0.854231 17.8544 1.34219V6.68088C16.3417 5.92646 14.6246 5.54765 12.7033 5.54765C11.0106 5.54765 9.76021 5.88473 8.95506 6.55889C8.14686 7.23304 7.74429 8.37911 7.74429 10.0003C7.74429 11.5669 8.14076 12.7001 8.93676 13.4C9.72971 14.103 10.9862 14.453 12.7063 14.453C14.527 14.453 16.2563 14.0067 17.8971 13.1175V18.7034C16.0763 19.5669 13.8073 19.9971 11.0899 19.9971C7.70159 19.9971 5.06961 19.1528 3.18483 17.4674Z" />
|
||||
<path d="M19.538 9.99679C19.538 6.73194 20.4224 4.2504 22.1913 2.54896C23.9602 0.847512 26.6257 0 30.1909 0C33.7805 0 36.4644 0.850722 38.2485 2.54896C40.0296 4.24719 40.9201 6.73194 40.9201 9.99679C40.9201 16.6613 37.3427 19.9936 30.1909 19.9936C23.0879 19.9968 19.538 16.6645 19.538 9.99679ZM32.7497 13.3997C33.2743 12.6966 33.5365 11.5634 33.5365 10C33.5365 8.46228 33.2743 7.33547 32.7497 6.61958C32.2251 5.90369 31.3712 5.54735 30.1909 5.54735C29.0381 5.54735 28.2024 5.9069 27.6901 6.61958C27.1777 7.33547 26.9215 8.46228 26.9215 10C26.9215 11.5666 27.1777 12.6998 27.6901 13.3997C28.2024 14.1027 29.035 14.4526 30.1909 14.4526C31.3712 14.4526 32.2221 14.0995 32.7497 13.3997Z" />
|
||||
<path d="M42.6029 0.404494H49.3704L49.5626 1.86196C50.3067 1.32263 51.2552 0.876404 52.408 0.526485C53.5608 0.176565 54.7533 0 55.9854 0C58.2667 0 59.9319 0.5939 60.9841 1.7817C62.0363 2.9695 62.5608 4.80257 62.5608 7.28732V19.5923H55.3328V8.05458C55.3328 7.19101 55.1467 6.57143 54.7747 6.19262C54.4026 5.8138 53.7804 5.62761 52.9082 5.62761C52.3714 5.62761 51.8194 5.75602 51.2552 6.01284C50.691 6.26966 50.2183 6.60032 49.8309 7.00482V19.5923H42.6029V0.404494Z" />
|
||||
<path d="M62.5818 0.404617H70.1178L73.5794 11.6566L77.0409 0.404617H84.5769L77.3855 19.5924H69.7702L62.5818 0.404617Z" />
|
||||
<path d="M86.8523 17.9422C84.6809 16.2279 83.6653 13.252 83.6653 10.0385C83.6653 6.90851 84.4735 4.33066 86.3186 2.54896C88.1637 0.767255 90.9757 0 94.5256 0C97.792 0 100.36 0.796147 102.236 2.38844C104.108 3.98074 105.047 6.15409 105.047 8.9053V12.2665H91.302C91.6436 13.2648 92.0766 13.9872 93.141 14.4334C94.2054 14.8796 95.6907 15.1011 97.5907 15.1011C98.7252 15.1011 99.8841 15.008 101.061 14.8186C101.476 14.7512 102.159 14.6453 102.519 14.565V19.2295C100.723 19.7432 98.3287 20 95.6297 20C91.9973 19.9968 89.0238 19.6565 86.8523 17.9422ZM97.4534 8.13804C97.4534 7.1878 96.4135 5.14286 94.3243 5.14286C92.4396 5.14286 91.1952 7.1557 91.1952 8.13804H97.4534Z" />
|
||||
<path d="M110.723 9.8364L103.955 0.404617H111.799L125.642 19.5924H117.722L114.645 15.3003L111.567 19.5924H103.684L110.723 9.8364Z" />
|
||||
<path d="M117.548 0.404617H125.356L119.363 8.8059L115.398 3.42227L117.548 0.404617Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5_2">
|
||||
<rect width="126" height="20" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -642,7 +642,8 @@ Default slash command settings:
|
||||
- `/focus <target>` bind current/new thread to a subagent/session target
|
||||
- `/unfocus` remove current thread binding
|
||||
- `/agents` show active runs and binding state
|
||||
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
|
||||
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
|
||||
- `/session max-age <duration|off>` inspect/update hard max age for focused bindings
|
||||
|
||||
Config:
|
||||
|
||||
@@ -651,14 +652,16 @@ Default slash command settings:
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
spawnSubagentSessions: false, // opt-in
|
||||
},
|
||||
},
|
||||
|
||||
@@ -109,6 +109,8 @@ On **Permissions**, click **Batch import** and paste:
|
||||
"application:application.app_message_stats.overview:readonly",
|
||||
"application:application:self_manage",
|
||||
"application:bot.menu:write",
|
||||
"cardkit:card:read",
|
||||
"cardkit:card:write",
|
||||
"contact:user.employee_id:readonly",
|
||||
"corehr:file:download",
|
||||
"event:ip_list",
|
||||
@@ -222,6 +224,34 @@ If your tenant is on Lark (international), set the domain to `lark` (or a full d
|
||||
}
|
||||
```
|
||||
|
||||
### Quota optimization flags
|
||||
|
||||
You can reduce Feishu API usage with two optional flags:
|
||||
|
||||
- `typingIndicator` (default `true`): when `false`, skip typing reaction calls.
|
||||
- `resolveSenderNames` (default `true`): when `false`, skip sender profile lookup calls.
|
||||
|
||||
Set them at top level or per account:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
typingIndicator: false,
|
||||
resolveSenderNames: false,
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
typingIndicator: true,
|
||||
resolveSenderNames: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Start + test
|
||||
@@ -315,14 +345,36 @@ After approval, you can chat normally.
|
||||
}
|
||||
```
|
||||
|
||||
### Allow specific users in groups only
|
||||
### Allow specific groups only
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["ou_xxx", "ou_yyy"],
|
||||
// Feishu group IDs (chat_id) look like: oc_xxx
|
||||
groupAllowFrom: ["oc_xxx", "oc_yyy"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Allow specific users to run control commands in a group (e.g. /reset, /new)
|
||||
|
||||
In addition to allowing the group itself, control commands are gated by the **sender** open_id.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["oc_xxx"],
|
||||
groups: {
|
||||
oc_xxx: {
|
||||
// Feishu user IDs (open_id) look like: ou_xxx
|
||||
allowFrom: ["ou_user1", "ou_user2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
summary: "Telegram Bot API integration via grammY with setup notes"
|
||||
read_when:
|
||||
- Working on Telegram or grammY pathways
|
||||
title: grammY
|
||||
---
|
||||
|
||||
# grammY Integration (Telegram Bot API)
|
||||
|
||||
# Why grammY
|
||||
|
||||
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
|
||||
- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods.
|
||||
- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context.
|
||||
|
||||
# What we shipped
|
||||
|
||||
- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default.
|
||||
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
Open questions
|
||||
|
||||
- Optional grammY plugins (throttler) if we hit Bot API 429s.
|
||||
- Add more structured media tests (stickers, voice notes).
|
||||
- Make webhook listen port configurable (currently fixed to 8787 unless wired through the gateway).
|
||||
@@ -43,6 +43,5 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/channels/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting).
|
||||
- Model providers are documented separately; see [Model Providers](/providers/models).
|
||||
|
||||
@@ -208,7 +208,8 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||
|
||||
- Native command auto-mode is **off** for Slack (`commands.native: "auto"` does not enable Slack native commands).
|
||||
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
|
||||
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names).
|
||||
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names), with one exception:
|
||||
- register `/agentstatus` for the status command (Slack reserves `/status`)
|
||||
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
|
||||
- Native arg menus now adapt their rendering strategy:
|
||||
- up to 5 options: button blocks
|
||||
|
||||
@@ -117,7 +117,7 @@ Token resolution order is account-aware. In practice, config values win over env
|
||||
`dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation.
|
||||
The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
|
||||
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can auto-migrate recovered entries into `channels.telegram.allowFrom`.
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet).
|
||||
|
||||
### Finding your Telegram user ID
|
||||
|
||||
@@ -138,10 +138,12 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Tab>
|
||||
|
||||
<Tab title="Group policy and allowlists">
|
||||
There are two independent controls:
|
||||
Two controls apply together:
|
||||
|
||||
1. **Which groups are allowed** (`channels.telegram.groups`)
|
||||
- no `groups` config: all groups allowed
|
||||
- no `groups` config:
|
||||
- with `groupPolicy: "open"`: any group can pass group-ID checks
|
||||
- with `groupPolicy: "allowlist"` (default): groups are blocked until you add `groups` entries (or `"*"`)
|
||||
- `groups` configured: acts as allowlist (explicit IDs or `"*"`)
|
||||
|
||||
2. **Which senders are allowed in groups** (`channels.telegram.groupPolicy`)
|
||||
@@ -150,8 +152,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `disabled`
|
||||
|
||||
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
|
||||
`groupAllowFrom` entries must be numeric Telegram user IDs.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set).
|
||||
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
|
||||
Non-numeric entries are ignored for sender authorization.
|
||||
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
|
||||
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime defaults to fail-closed `groupPolicy="allowlist"` unless `channels.defaults.groupPolicy` is explicitly set.
|
||||
|
||||
Example: allow any member in one specific group:
|
||||
|
||||
@@ -385,17 +390,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `react` (`chatId`, `messageId`, `emoji`)
|
||||
- `deleteMessage` (`chatId`, `messageId`)
|
||||
- `editMessage` (`chatId`, `messageId`, `content`)
|
||||
- `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`)
|
||||
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`).
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
|
||||
|
||||
Gating controls:
|
||||
|
||||
- `channels.telegram.actions.sendMessage`
|
||||
- `channels.telegram.actions.editMessage`
|
||||
- `channels.telegram.actions.deleteMessage`
|
||||
- `channels.telegram.actions.reactions`
|
||||
- `channels.telegram.actions.sticker` (default: disabled)
|
||||
|
||||
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
|
||||
|
||||
Reaction removal semantics: [/tools/reactions](/tools/reactions)
|
||||
|
||||
</Accordion>
|
||||
@@ -612,6 +619,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- set `channels.telegram.webhookSecret` (required when webhook URL is set)
|
||||
- optional `channels.telegram.webhookPath` (default `/telegram-webhook`)
|
||||
- optional `channels.telegram.webhookHost` (default `127.0.0.1`)
|
||||
- optional `channels.telegram.webhookPort` (default `8787`)
|
||||
|
||||
Default local listener for webhook mode binds to `127.0.0.1:8787`.
|
||||
|
||||
@@ -629,7 +637,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- DM history controls:
|
||||
- `channels.telegram.dmHistoryLimit`
|
||||
- `channels.telegram.dms["<user_id>"].historyLimit`
|
||||
- outbound Telegram API retries are configurable via `channels.telegram.retry`.
|
||||
- `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors.
|
||||
|
||||
CLI send target can be numeric chat ID or username:
|
||||
|
||||
@@ -718,9 +726,10 @@ Primary reference:
|
||||
- `channels.telegram.botToken`: bot token (BotFather).
|
||||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can restore allowlist entries from pairing-store files when available.
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
|
||||
- `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
- Multi-account precedence:
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
@@ -737,13 +746,14 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `off`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
|
||||
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+.
|
||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||
@@ -751,6 +761,7 @@ Primary reference:
|
||||
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
|
||||
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||
- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`).
|
||||
- `channels.telegram.webhookPort`: local webhook bind port (default `8787`).
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
@@ -764,7 +775,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||
- command/menu: `commands.native`, `customCommands`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
|
||||
@@ -22,6 +22,7 @@ Compaction **persists** in the session’s JSONL history.
|
||||
## Configuration
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
@@ -54,6 +55,18 @@ Context window is model-specific. OpenClaw uses the model definition from the co
|
||||
|
||||
See [/concepts/session-pruning](/concepts/session-pruning) for pruning details.
|
||||
|
||||
## OpenAI server-side compaction
|
||||
|
||||
OpenClaw also supports OpenAI Responses server-side compaction hints for
|
||||
compatible direct OpenAI models. This is separate from local OpenClaw
|
||||
compaction and can run alongside it.
|
||||
|
||||
- Local compaction: OpenClaw summarizes and persists into session JSONL.
|
||||
- Server-side compaction: OpenAI compacts context on the provider side when
|
||||
`store` + `context_management` are enabled.
|
||||
|
||||
See [OpenAI provider](/providers/openai) for model params and overrides.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `/compact` when sessions feel stale or context is bloated.
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/providers/grammy",
|
||||
"destination": "/channels/grammy"
|
||||
"destination": "/channels/telegram"
|
||||
},
|
||||
{
|
||||
"source": "/providers/imessage",
|
||||
@@ -365,7 +365,11 @@
|
||||
},
|
||||
{
|
||||
"source": "/grammy",
|
||||
"destination": "/channels/grammy"
|
||||
"destination": "/channels/telegram"
|
||||
},
|
||||
{
|
||||
"source": "/channels/grammy",
|
||||
"destination": "/channels/telegram"
|
||||
},
|
||||
{
|
||||
"source": "/group-messages",
|
||||
@@ -1271,12 +1275,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Technical reference",
|
||||
"pages": [
|
||||
"reference/wizard",
|
||||
"reference/token-use",
|
||||
"reference/prompt-caching",
|
||||
"channels/grammy"
|
||||
]
|
||||
"pages": ["reference/wizard", "reference/token-use", "reference/prompt-caching"]
|
||||
},
|
||||
{
|
||||
"group": "Concept internals",
|
||||
|
||||
@@ -638,7 +638,7 @@ Add independent ACP dispatch kill switch:
|
||||
|
||||
- `/focus <sessionKey>` continues to support ACP targets
|
||||
- `/unfocus` keeps current semantics
|
||||
- `/session ttl` remains the top level TTL override
|
||||
- `/session idle` and `/session max-age` replace the old TTL override
|
||||
|
||||
## Phased rollout
|
||||
|
||||
|
||||
@@ -65,6 +65,30 @@ Use `channels.modelByChannel` to pin specific channel IDs to a model. Values acc
|
||||
}
|
||||
```
|
||||
|
||||
### Channel defaults and heartbeat
|
||||
|
||||
Use `channels.defaults` for shared group-policy and heartbeat behavior across providers:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
defaults: {
|
||||
groupPolicy: "allowlist", // open | allowlist | disabled
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
showAlerts: true,
|
||||
useIndicator: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `channels.defaults.groupPolicy`: fallback group policy when a provider-level `groupPolicy` is unset.
|
||||
- `channels.defaults.heartbeat.showOk`: include healthy channel statuses in heartbeat output.
|
||||
- `channels.defaults.heartbeat.showAlerts`: include degraded/error statuses in heartbeat output.
|
||||
- `channels.defaults.heartbeat.useIndicator`: render compact indicator-style heartbeat output.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists.
|
||||
@@ -244,7 +268,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true })
|
||||
},
|
||||
voice: {
|
||||
@@ -279,8 +304,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
|
||||
- `channels.discord.threadBindings` controls Discord thread-bound routing:
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`, and bound delivery/routing)
|
||||
- `ttlHours`: Discord override for auto-unfocus TTL (`0` disables)
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
|
||||
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
|
||||
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
|
||||
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
||||
@@ -422,12 +448,20 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
|
||||
Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix).
|
||||
|
||||
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
|
||||
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
|
||||
|
||||
### Signal
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
signal: {
|
||||
enabled: true,
|
||||
account: "+15555550123", // optional account binding
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
configWrites: true,
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
historyLimit: 50,
|
||||
@@ -438,6 +472,29 @@ Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message
|
||||
|
||||
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
|
||||
|
||||
- `channels.signal.account`: pin channel startup to a specific Signal account identity.
|
||||
- `channels.signal.configWrites`: allow or deny Signal-initiated config writes.
|
||||
|
||||
### BlueBubbles
|
||||
|
||||
BlueBubbles is the recommended iMessage path (plugin-backed, configured under `channels.bluebubbles`).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
// serverUrl, password, webhookPath, group controls, and advanced actions:
|
||||
// see /channels/bluebubbles
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.bluebubbles`, `channels.bluebubbles.dmPolicy`.
|
||||
- Full BlueBubbles channel configuration is documented in [BlueBubbles](/channels/bluebubbles).
|
||||
|
||||
### iMessage
|
||||
|
||||
OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
@@ -469,6 +526,7 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
|
||||
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
|
||||
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
|
||||
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
|
||||
|
||||
<Accordion title="iMessage SSH wrapper example">
|
||||
|
||||
@@ -479,6 +537,52 @@ exec ssh -T gateway-host imsg "$@"
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Microsoft Teams
|
||||
|
||||
Microsoft Teams is extension-backed and configured under `channels.msteams`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
configWrites: true,
|
||||
// appId, appPassword, tenantId, webhook, team/channel policies:
|
||||
// see /channels/msteams
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.msteams`, `channels.msteams.configWrites`.
|
||||
- Full Teams config (credentials, webhook, DM/group policy, per-team/per-channel overrides) is documented in [Microsoft Teams](/channels/msteams).
|
||||
|
||||
### IRC
|
||||
|
||||
IRC is extension-backed and configured under `channels.irc`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
configWrites: true,
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
password: "${IRC_NICKSERV_PASSWORD}",
|
||||
register: false,
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.irc`, `channels.irc.dmPolicy`, `channels.irc.configWrites`, `channels.irc.nickserv.*`.
|
||||
- Full IRC channel configuration (host/port/TLS/channels/allowlists/mention gating) is documented in [IRC](/channels/irc).
|
||||
|
||||
### Multi-account (all channels)
|
||||
|
||||
Run multiple accounts per channel (each with its own `accountId`):
|
||||
@@ -510,6 +614,11 @@ Run multiple accounts per channel (each with its own `accountId`):
|
||||
- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional.
|
||||
- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing.
|
||||
|
||||
### Other extension channels
|
||||
|
||||
Many extension channels are configured as `channels.<id>` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch).
|
||||
See the full channel index: [Channels](/channels).
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
@@ -830,6 +939,8 @@ Periodic heartbeat runs.
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -843,6 +954,8 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
@@ -1267,7 +1380,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24, // default auto-unfocus TTL for thread-bound sessions (0 disables)
|
||||
idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables)
|
||||
maxAgeHours: 0, // default hard max age in hours (`0` disables)
|
||||
},
|
||||
mainKey: "main", // legacy (runtime always uses "main")
|
||||
agentToAgent: { maxPingPongTurns: 5 },
|
||||
@@ -1304,7 +1418,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
- `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`.
|
||||
- **`threadBindings`**: global defaults for thread-bound session features.
|
||||
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
|
||||
- `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override)
|
||||
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)
|
||||
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1748,8 +1863,29 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
- Merge precedence for matching provider IDs:
|
||||
- Non-empty agent `models.json` `apiKey`/`baseUrl` win.
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
|
||||
- Matching model `contextWindow`/`maxTokens` use the higher value between explicit config and implicit catalog values.
|
||||
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
|
||||
|
||||
### Provider field details
|
||||
|
||||
- `models.mode`: provider catalog behavior (`merge` or `replace`).
|
||||
- `models.providers`: custom provider map keyed by provider id.
|
||||
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
|
||||
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
|
||||
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
|
||||
- `models.providers.*.injectNumCtxForOpenAICompat`: for Ollama + `openai-completions`, inject `options.num_ctx` into requests (default: `true`).
|
||||
- `models.providers.*.authHeader`: force credential transport in the `Authorization` header when required.
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.bedrockDiscovery`: Bedrock auto-discovery settings root.
|
||||
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
||||
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
||||
- `models.bedrockDiscovery.providerFilter`: optional provider-id filter for targeted discovery.
|
||||
- `models.bedrockDiscovery.refreshInterval`: polling interval for discovery refresh.
|
||||
- `models.bedrockDiscovery.defaultContextWindow`: fallback context window for discovered models.
|
||||
- `models.bedrockDiscovery.defaultMaxTokens`: fallback max output tokens for discovered models.
|
||||
|
||||
### Provider examples
|
||||
|
||||
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
|
||||
@@ -2027,6 +2163,13 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by plugin schema).
|
||||
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
|
||||
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.
|
||||
- Includes `source`, `spec`, `sourcePath`, `installPath`, `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, `shasum`, `resolvedAt`, `installedAt`.
|
||||
- Treat `plugins.installs.*` as managed state; prefer CLI commands over manual edits.
|
||||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
@@ -2149,11 +2292,11 @@ See [Plugins](/tools/plugin).
|
||||
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
|
||||
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
|
||||
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
|
||||
- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
|
||||
- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
|
||||
- `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `gateway.auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
|
||||
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
|
||||
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
|
||||
@@ -2599,7 +2742,7 @@ See [Cron Jobs](/automation/cron-jobs).
|
||||
|
||||
## Media model template variables
|
||||
|
||||
Template placeholders expanded in `tools.media.*.models[].args`:
|
||||
Template placeholders expanded in `tools.media.models[].args`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
|
||||
@@ -184,7 +184,8 @@ When validation fails:
|
||||
dmScope: "per-channel-peer", // recommended for multi-user
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
},
|
||||
reset: {
|
||||
mode: "daily",
|
||||
@@ -196,7 +197,7 @@ When validation fails:
|
||||
```
|
||||
|
||||
- `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer`
|
||||
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, and `/session ttl`).
|
||||
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, `/session idle`, and `/session max-age`).
|
||||
- See [Session Management](/concepts/session) for scoping, identity links, and send policy.
|
||||
- See [full reference](/gateway/configuration-reference#session) for all fields.
|
||||
|
||||
|
||||
@@ -215,6 +215,28 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
is enabled for break-glass use.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
### Device auth migration diagnostics
|
||||
|
||||
For legacy clients that still use pre-challenge signing behavior, `connect` now returns
|
||||
`DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
|
||||
|
||||
Common migration failures:
|
||||
|
||||
| Message | details.code | details.reason | Meaning |
|
||||
| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- |
|
||||
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
|
||||
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
|
||||
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
|
||||
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
|
||||
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
|
||||
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
|
||||
|
||||
Migration target:
|
||||
|
||||
- Always wait for `connect.challenge`.
|
||||
- Sign the v2 payload that includes the server nonce.
|
||||
- Send the same nonce in `connect.params.device.nonce`.
|
||||
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
|
||||
in addition to device/client/role/scopes/token/nonce fields.
|
||||
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
|
||||
|
||||
@@ -80,9 +80,27 @@ Look for:
|
||||
Common signatures:
|
||||
|
||||
- `device identity required` → non-secure context or missing device auth.
|
||||
- `device nonce required` / `device nonce mismatch` → client is not completing the
|
||||
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
|
||||
- `device signature invalid` / `device signature expired` → client signed the wrong
|
||||
payload (or stale timestamp) for the current handshake.
|
||||
- `unauthorized` / reconnect loop → token/password mismatch.
|
||||
- `gateway connect failed:` → wrong host/port/url target.
|
||||
|
||||
Device auth v2 migration check:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
openclaw doctor
|
||||
openclaw gateway status
|
||||
```
|
||||
|
||||
If logs show nonce/signature errors, update the connecting client and verify it:
|
||||
|
||||
1. waits for `connect.challenge`
|
||||
2. signs the challenge-bound payload
|
||||
3. sends `connect.params.device.nonce` with the same challenge nonce
|
||||
|
||||
Related:
|
||||
|
||||
- [/web/control-ui](/web/control-ui)
|
||||
|
||||
@@ -1050,13 +1050,13 @@ Basic flow:
|
||||
- Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"` for persistent follow-up).
|
||||
- Or manually bind with `/focus <target>`.
|
||||
- Use `/agents` to inspect binding state.
|
||||
- Use `/session ttl <duration|off>` to control auto-unfocus.
|
||||
- Use `/session idle <duration|off>` and `/session max-age <duration|off>` to control auto-unfocus.
|
||||
- Use `/unfocus` to detach the thread.
|
||||
|
||||
Required config:
|
||||
|
||||
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`.
|
||||
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`.
|
||||
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`.
|
||||
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`.
|
||||
- Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`.
|
||||
|
||||
Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands).
|
||||
|
||||
@@ -101,6 +101,23 @@ Use this decision table:
|
||||
- Touching gateway networking / WS protocol / pairing: add `pnpm test:e2e`
|
||||
- Debugging “my bot is down” / provider-specific failures / tool calling: run a narrowed `pnpm test:live`
|
||||
|
||||
## Live: Android node capability sweep
|
||||
|
||||
- Test: `src/gateway/android-node.capabilities.live.test.ts`
|
||||
- Script: `pnpm android:test:integration`
|
||||
- Goal: invoke **every command currently advertised** by a connected Android node and assert command contract behavior.
|
||||
- Scope:
|
||||
- Preconditioned/manual setup (the suite does not install/run/pair the app).
|
||||
- Command-by-command gateway `node.invoke` validation for the selected Android node.
|
||||
- Required pre-setup:
|
||||
- Android app already connected + paired to the gateway.
|
||||
- App kept in foreground.
|
||||
- Permissions/capture consent granted for capabilities you expect to pass.
|
||||
- Optional target overrides:
|
||||
- `OPENCLAW_ANDROID_NODE_ID` or `OPENCLAW_ANDROID_NODE_NAME`.
|
||||
- `OPENCLAW_ANDROID_GATEWAY_URL` / `OPENCLAW_ANDROID_GATEWAY_TOKEN` / `OPENCLAW_ANDROID_GATEWAY_PASSWORD`.
|
||||
- Full Android setup details: [Android App](/platforms/android)
|
||||
|
||||
## Live: model smoke (profile keys)
|
||||
|
||||
Live tests are split into two layers so we can isolate failures:
|
||||
|
||||
@@ -100,6 +100,12 @@ If permissions are missing, the app will prompt when possible; if denied, `camer
|
||||
|
||||
Like `canvas.*`, the Android node only allows `camera.*` commands in the **foreground**. Background invocations return `NODE_BACKGROUND_UNAVAILABLE`.
|
||||
|
||||
### Android commands (via Gateway `node.invoke`)
|
||||
|
||||
- `camera.list`
|
||||
- Response payload:
|
||||
- `devices`: array of `{ id, name, position, deviceType }`
|
||||
|
||||
### Payload guard
|
||||
|
||||
Photos are recompressed to keep the base64 payload under 5 MB.
|
||||
|
||||
@@ -27,24 +27,26 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
||||
Notes:
|
||||
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
|
||||
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
|
||||
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
|
||||
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
APP_VERSION=2026.2.27 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.26.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.27.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.26.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.27.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -52,14 +54,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.26.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
APP_VERSION=2026.2.27 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.26.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.27.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -67,7 +68,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.26.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.27.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@@ -75,7 +76,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.2.26.zip` (and `OpenClaw-2026.2.26.dSYM.zip`) to the GitHub release for tag `v2026.2.26`.
|
||||
- Upload `OpenClaw-2026.2.27.zip` (and `OpenClaw-2026.2.27.dSYM.zip`) to the GitHub release for tag `v2026.2.27`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -10,6 +10,10 @@ title: "Ollama"
|
||||
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
|
||||
<Warning>
|
||||
**Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`).
|
||||
</Warning>
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install Ollama: [https://ollama.ai](https://ollama.ai)
|
||||
@@ -133,13 +137,18 @@ If Ollama is running on a different host or port (explicit config disables auto-
|
||||
providers: {
|
||||
ollama: {
|
||||
apiKey: "ollama-local",
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
baseUrl: "http://ollama-host:11434", // No /v1 - use native Ollama API URL
|
||||
api: "ollama", // Set explicitly to guarantee native tool-calling behavior
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Do not add `/v1` to the URL. The `/v1` path uses OpenAI-compatible mode, where tool calling is not reliable. Use the base Ollama URL without a path suffix.
|
||||
</Warning>
|
||||
|
||||
### Model selection
|
||||
|
||||
Once configured, all your Ollama models are available:
|
||||
@@ -177,6 +186,10 @@ OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by de
|
||||
|
||||
#### Legacy OpenAI-Compatible Mode
|
||||
|
||||
<Warning>
|
||||
**Tool calling is not reliable in OpenAI-compatible mode.** Use this mode only if you need OpenAI format for a proxy and do not depend on native tool calling behavior.
|
||||
</Warning>
|
||||
|
||||
If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy that only supports OpenAI format), set `api: "openai-completions"` explicitly:
|
||||
|
||||
```json5
|
||||
@@ -186,6 +199,7 @@ If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
api: "openai-completions",
|
||||
injectNumCtxForOpenAICompat: true, // default: true
|
||||
apiKey: "ollama-local",
|
||||
models: [...]
|
||||
}
|
||||
@@ -194,7 +208,25 @@ If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy
|
||||
}
|
||||
```
|
||||
|
||||
Note: The OpenAI-compatible endpoint may not support streaming + tool calling simultaneously. You may need to disable streaming with `params: { streaming: false }` in model config.
|
||||
This mode may not support streaming + tool calling simultaneously. You may need to disable streaming with `params: { streaming: false }` in model config.
|
||||
|
||||
When `api: "openai-completions"` is used with Ollama, OpenClaw injects `options.num_ctx` by default so Ollama does not silently fall back to a 4096 context window. If your proxy/upstream rejects unknown `options` fields, disable this behavior:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
api: "openai-completions",
|
||||
injectNumCtxForOpenAICompat: false,
|
||||
apiKey: "ollama-local",
|
||||
models: [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context windows
|
||||
|
||||
|
||||
@@ -83,6 +83,80 @@ OpenClaw uses `pi-ai` for model streaming. For `openai-codex/*` models you can s
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI Responses server-side compaction
|
||||
|
||||
For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with
|
||||
`baseUrl` on `api.openai.com`), OpenClaw now auto-enables OpenAI server-side
|
||||
compaction payload hints:
|
||||
|
||||
- Forces `store: true` (unless model compat sets `supportsStore: false`)
|
||||
- Injects `context_management: [{ type: "compaction", compact_threshold: ... }]`
|
||||
|
||||
By default, `compact_threshold` is `70%` of model `contextWindow` (or `80000`
|
||||
when unavailable).
|
||||
|
||||
### Enable server-side compaction explicitly
|
||||
|
||||
Use this when you want to force `context_management` injection on compatible
|
||||
Responses models (for example Azure OpenAI Responses):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"azure-openai-responses/gpt-4o": {
|
||||
params: {
|
||||
responsesServerCompaction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Enable with a custom threshold
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5": {
|
||||
params: {
|
||||
responsesServerCompaction: true,
|
||||
responsesCompactThreshold: 120000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Disable server-side compaction
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5": {
|
||||
params: {
|
||||
responsesServerCompaction: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`responsesServerCompaction` only controls `context_management` injection.
|
||||
Direct OpenAI Responses models still force `store: true` unless compat sets
|
||||
`supportsStore: false`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Model refs always use `provider/model` (see [/concepts/models](/concepts/models)).
|
||||
|
||||
@@ -73,7 +73,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Model providers hub](/providers/models)
|
||||
- [WhatsApp](/channels/whatsapp)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Telegram (grammY notes)](/channels/grammy)
|
||||
- [Slack](/channels/slack)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost) (plugin)
|
||||
|
||||
@@ -68,7 +68,7 @@ When thread bindings are enabled for a channel adapter, ACP sessions can be boun
|
||||
- OpenClaw binds a thread to a target ACP session.
|
||||
- Follow-up messages in that thread route to the bound ACP session.
|
||||
- ACP output is delivered back to the same thread.
|
||||
- Unfocus/close/archive/TTL expiry removes the binding.
|
||||
- Unfocus/close/archive/idle-timeout or max-age expiry removes the binding.
|
||||
|
||||
Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
|
||||
|
||||
@@ -272,7 +272,8 @@ Thread binding config is channel-adapter specific. Example for Discord:
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
|
||||
@@ -354,8 +354,9 @@ Core actions:
|
||||
- `pending`, `approve`, `reject` (pairing)
|
||||
- `notify` (macOS `system.notify`)
|
||||
- `run` (macOS `system.run`)
|
||||
- `camera_snap`, `camera_clip`, `screen_record`
|
||||
- `location_get`
|
||||
- `camera_list`, `camera_snap`, `camera_clip`, `screen_record`
|
||||
- `location_get`, `notifications_list`, `notifications_action`
|
||||
- `device_status`, `device_info`, `device_permissions`, `device_health`
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ Text + native (when enabled):
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/session ttl <duration|off>` (manage session-level settings, such as TTL)
|
||||
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
|
||||
- `/session max-age <duration|off>` (manage hard max-age auto-unfocus for focused thread bindings)
|
||||
- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session)
|
||||
- `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions)
|
||||
- `/agents` (list thread-bound agents for this session)
|
||||
@@ -125,7 +126,7 @@ Notes:
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
|
||||
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
||||
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
|
||||
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
|
||||
- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents).
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
|
||||
@@ -218,3 +219,4 @@ Notes:
|
||||
- Telegram: `telegram:slash:<userId>` (targets the chat session via `CommandTargetSessionKey`)
|
||||
- **`/stop`** targets the active chat session so it can abort the current run.
|
||||
- **Slack:** `channels.slack.slashCommand` is still supported for a single `/openclaw`-style command. If you enable `commands.native`, you must create one Slack slash command per built-in command (same names as `/help`). Command argument menus for Slack are delivered as ephemeral Block Kit buttons.
|
||||
- Slack native exception: register `/agentstatus` (not `/status`) because Slack reserves `/status`. Text `/status` still works in Slack messages.
|
||||
|
||||
@@ -30,7 +30,8 @@ These commands work on channels that support persistent thread bindings. See **T
|
||||
- `/focus <subagent-label|session-key|session-id|session-label>`
|
||||
- `/unfocus`
|
||||
- `/agents`
|
||||
- `/session ttl <duration|off>`
|
||||
- `/session idle <duration|off>`
|
||||
- `/session max-age <duration|off>`
|
||||
|
||||
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
|
||||
|
||||
@@ -95,14 +96,14 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a
|
||||
|
||||
### Thread supporting channels
|
||||
|
||||
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
|
||||
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
|
||||
|
||||
Quick flow:
|
||||
|
||||
1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`).
|
||||
2. OpenClaw creates or binds a thread to that session target in the active channel.
|
||||
3. Replies and follow-up messages in that thread route to the bound session.
|
||||
4. Use `/session ttl` to inspect/update auto-unfocus TTL.
|
||||
4. Use `/session idle` to inspect/update inactivity auto-unfocus and `/session max-age` to control the hard cap.
|
||||
5. Use `/unfocus` to detach manually.
|
||||
|
||||
Manual controls:
|
||||
@@ -110,11 +111,11 @@ Manual controls:
|
||||
- `/focus <target>` binds the current thread (or creates one) to a sub-agent/session target.
|
||||
- `/unfocus` removes the binding for the current bound thread.
|
||||
- `/agents` lists active runs and binding state (`thread:<id>` or `unbound`).
|
||||
- `/session ttl` only works for focused bound threads.
|
||||
- `/session idle` and `/session max-age` only work for focused bound threads.
|
||||
|
||||
Config switches:
|
||||
|
||||
- Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`
|
||||
- Global default: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`
|
||||
- Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above.
|
||||
|
||||
See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.2.26",
|
||||
"version": "2026.2.27",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.26",
|
||||
"version": "2026.2.27",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.26",
|
||||
"version": "2026.2.27",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.26",
|
||||
"version": "2026.2.27",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
153
extensions/diffs/README.md
Normal file
153
extensions/diffs/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# @openclaw/diffs
|
||||
|
||||
Read-only diff viewer plugin for **OpenClaw** agents.
|
||||
|
||||
It gives agents one tool, `diffs`, that can:
|
||||
|
||||
- render a gateway-hosted diff viewer for canvas use
|
||||
- render the same diff to a PNG image
|
||||
- accept either arbitrary `before`/`after` text or a unified patch
|
||||
|
||||
## What Agents Get
|
||||
|
||||
The tool can return:
|
||||
|
||||
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
|
||||
- `details.imagePath`: a local PNG artifact when image rendering is requested
|
||||
|
||||
This means an agent can:
|
||||
|
||||
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
|
||||
- call `diffs` with `mode=image`, then send the PNG through the normal `message` tool using `path` or `filePath`
|
||||
- call `diffs` with `mode=both` when it wants both outputs
|
||||
|
||||
## Tool Inputs
|
||||
|
||||
Before/after:
|
||||
|
||||
```json
|
||||
{
|
||||
"before": "# Hello\n\nOne",
|
||||
"after": "# Hello\n\nTwo",
|
||||
"path": "docs/example.md",
|
||||
"mode": "view"
|
||||
}
|
||||
```
|
||||
|
||||
Patch:
|
||||
|
||||
```json
|
||||
{
|
||||
"patch": "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-const x = 1;\n+const x = 2;\n",
|
||||
"mode": "both"
|
||||
}
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
- `mode`: `view`, `image`, or `both`
|
||||
- `layout`: `unified` or `split`
|
||||
- `theme`: `light` or `dark` (default: `dark`)
|
||||
- `expandUnchanged`: expand unchanged sections
|
||||
- `path`: display name for before/after input
|
||||
- `title`: explicit viewer title
|
||||
- `ttlSeconds`: artifact lifetime
|
||||
- `baseUrl`: override the gateway base URL used in the returned viewer link
|
||||
|
||||
## Plugin Defaults
|
||||
|
||||
Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
diffs: {
|
||||
enabled: true,
|
||||
config: {
|
||||
defaults: {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
layout: "unified",
|
||||
wordWrap: true,
|
||||
background: true,
|
||||
theme: "dark",
|
||||
mode: "both",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Explicit tool parameters still win over these defaults.
|
||||
|
||||
## Example Agent Prompts
|
||||
|
||||
Open in canvas:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `view` mode for this before/after content, then open the returned viewer URL in the canvas.
|
||||
|
||||
Path: docs/example.md
|
||||
|
||||
Before:
|
||||
# Hello
|
||||
|
||||
This is version one.
|
||||
|
||||
After:
|
||||
# Hello
|
||||
|
||||
This is version two.
|
||||
```
|
||||
|
||||
Render a PNG:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `image` mode for this before/after input. After it returns `details.imagePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
|
||||
|
||||
Path: README.md
|
||||
|
||||
Before:
|
||||
OpenClaw supports plugins.
|
||||
|
||||
After:
|
||||
OpenClaw supports plugins and hosted diff views.
|
||||
```
|
||||
|
||||
Do both:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered PNG by passing `details.imagePath` to the `message` tool.
|
||||
|
||||
Path: src/demo.ts
|
||||
|
||||
Before:
|
||||
const status = "old";
|
||||
|
||||
After:
|
||||
const status = "new";
|
||||
```
|
||||
|
||||
Patch input:
|
||||
|
||||
```text
|
||||
Use the `diffs` tool with this unified patch in `view` mode. After it returns the viewer URL, present it in the canvas.
|
||||
|
||||
diff --git a/src/example.ts b/src/example.ts
|
||||
--- a/src/example.ts
|
||||
+++ b/src/example.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
export function add(a: number, b: number) {
|
||||
- return a + b;
|
||||
+ return a + b + 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the local temp directory.
|
||||
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
|
||||
1305
extensions/diffs/assets/viewer-runtime.js
Normal file
1305
extensions/diffs/assets/viewer-runtime.js
Normal file
File diff suppressed because one or more lines are too long
123
extensions/diffs/index.test.ts
Normal file
123
extensions/diffs/index.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http handler, and prompt guidance hook", () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpHandler = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
registerTool,
|
||||
registerHook() {},
|
||||
registerHttpHandler,
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerCommand() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on,
|
||||
});
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpHandler).toHaveBeenCalledTimes(1);
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
let registeredTool:
|
||||
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
|
||||
| undefined;
|
||||
let registeredHttpHandler:
|
||||
| ((
|
||||
req: IncomingMessage,
|
||||
res: ReturnType<typeof createMockServerResponse>,
|
||||
) => Promise<boolean>)
|
||||
| undefined;
|
||||
|
||||
plugin.register?.({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
registerTool(tool) {
|
||||
registeredTool = typeof tool === "function" ? undefined : tool;
|
||||
},
|
||||
registerHook() {},
|
||||
registerHttpHandler(handler) {
|
||||
registeredHttpHandler = handler as typeof registeredHttpHandler;
|
||||
},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerCommand() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on() {},
|
||||
});
|
||||
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpHandler?.(
|
||||
{
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
});
|
||||
});
|
||||
30
extensions/diffs/index.ts
Normal file
30
extensions/diffs/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||
import { diffsPluginConfigSchema, resolveDiffsPluginDefaults } from "./src/config.js";
|
||||
import { createDiffsHttpHandler } from "./src/http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./src/store.js";
|
||||
import { createDiffsTool } from "./src/tool.js";
|
||||
|
||||
const plugin = {
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Read-only diff viewer and PNG renderer for agents.",
|
||||
configSchema: diffsPluginConfigSchema,
|
||||
register(api: OpenClawPluginApi) {
|
||||
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
logger: api.logger,
|
||||
});
|
||||
|
||||
api.registerTool(createDiffsTool({ api, store, defaults }));
|
||||
api.registerHttpHandler(createDiffsHttpHandler({ store, logger: api.logger }));
|
||||
api.on("before_prompt_build", async () => ({
|
||||
prependContext: DIFFS_AGENT_GUIDANCE,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
80
extensions/diffs/openclaw.plugin.json
Normal file
80
extensions/diffs/openclaw.plugin.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"id": "diffs",
|
||||
"name": "Diffs",
|
||||
"description": "Read-only diff viewer and image renderer for agents.",
|
||||
"uiHints": {
|
||||
"defaults.fontFamily": {
|
||||
"label": "Default Font",
|
||||
"help": "Preferred font family name for diff content and headers."
|
||||
},
|
||||
"defaults.fontSize": {
|
||||
"label": "Default Font Size",
|
||||
"help": "Base diff font size in pixels."
|
||||
},
|
||||
"defaults.layout": {
|
||||
"label": "Default Layout",
|
||||
"help": "Initial diff layout shown in the viewer."
|
||||
},
|
||||
"defaults.wordWrap": {
|
||||
"label": "Default Word Wrap",
|
||||
"help": "Wrap long lines by default."
|
||||
},
|
||||
"defaults.background": {
|
||||
"label": "Default Background Highlights",
|
||||
"help": "Show added/removed background highlights by default."
|
||||
},
|
||||
"defaults.theme": {
|
||||
"label": "Default Theme",
|
||||
"help": "Initial viewer theme."
|
||||
},
|
||||
"defaults.mode": {
|
||||
"label": "Default Output Mode",
|
||||
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fontFamily": {
|
||||
"type": "string",
|
||||
"default": "Fira Code"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "number",
|
||||
"minimum": 10,
|
||||
"maximum": 24,
|
||||
"default": 15
|
||||
},
|
||||
"layout": {
|
||||
"type": "string",
|
||||
"enum": ["unified", "split"],
|
||||
"default": "unified"
|
||||
},
|
||||
"wordWrap": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
"default": "dark"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["view", "image", "both"],
|
||||
"default": "both"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
extensions/diffs/package.json
Normal file
20
extensions/diffs/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.2.27",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.0.11",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
261
extensions/diffs/src/browser.ts
Normal file
261
extensions/diffs/src/browser.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { chromium } from "playwright-core";
|
||||
import type { DiffTheme } from "./types.js";
|
||||
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
|
||||
|
||||
export type DiffScreenshotter = {
|
||||
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
|
||||
};
|
||||
|
||||
export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
|
||||
private readonly config: OpenClawConfig;
|
||||
|
||||
constructor(params: { config: OpenClawConfig }) {
|
||||
this.config = params.config;
|
||||
}
|
||||
|
||||
async screenshotHtml(params: {
|
||||
html: string;
|
||||
outputPath: string;
|
||||
theme: DiffTheme;
|
||||
}): Promise<string> {
|
||||
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
|
||||
const executablePath = await resolveBrowserExecutablePath(this.config);
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: ["--disable-dev-shm-usage", "--disable-gpu"],
|
||||
});
|
||||
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 900 },
|
||||
colorScheme: params.theme,
|
||||
});
|
||||
await page.route(`http://127.0.0.1${VIEWER_ASSET_PREFIX}*`, async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const asset = await getServedViewerAsset(pathname);
|
||||
if (!asset) {
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: asset.contentType,
|
||||
body: asset.body,
|
||||
});
|
||||
});
|
||||
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
if (document.documentElement.dataset.openclawDiffsReady === "true") {
|
||||
return true;
|
||||
}
|
||||
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
|
||||
return (
|
||||
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
|
||||
);
|
||||
});
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const frame = document.querySelector(".oc-frame");
|
||||
if (frame instanceof HTMLElement) {
|
||||
frame.dataset.renderMode = "image";
|
||||
}
|
||||
});
|
||||
|
||||
const frame = page.locator(".oc-frame");
|
||||
await frame.waitFor();
|
||||
const initialBox = await frame.boundingBox();
|
||||
if (!initialBox) {
|
||||
throw new Error("Diff frame did not render.");
|
||||
}
|
||||
|
||||
const padding = 20;
|
||||
const clipWidth = Math.ceil(initialBox.width + padding * 2);
|
||||
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
|
||||
await page.setViewportSize({
|
||||
width: Math.max(clipWidth + padding, 900),
|
||||
height: Math.max(clipHeight + padding, 700),
|
||||
});
|
||||
|
||||
const box = await frame.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Diff frame was lost after resizing.");
|
||||
}
|
||||
|
||||
await page.screenshot({
|
||||
path: params.outputPath,
|
||||
type: "png",
|
||||
clip: {
|
||||
x: Math.max(box.x - padding, 0),
|
||||
y: Math.max(box.y - padding, 0),
|
||||
width: clipWidth,
|
||||
height: clipHeight,
|
||||
},
|
||||
});
|
||||
return params.outputPath;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Diff image rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
|
||||
);
|
||||
} finally {
|
||||
await browser?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function injectBaseHref(html: string): string {
|
||||
if (html.includes("<base ")) {
|
||||
return html;
|
||||
}
|
||||
return html.replace("<head>", '<head><base href="http://127.0.0.1/" />');
|
||||
}
|
||||
|
||||
async function resolveBrowserExecutablePath(config: OpenClawConfig): Promise<string | undefined> {
|
||||
const configPath = config.browser?.executablePath?.trim();
|
||||
if (configPath) {
|
||||
await assertExecutable(configPath, "browser.executablePath");
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const envCandidates = [
|
||||
process.env.OPENCLAW_BROWSER_EXECUTABLE_PATH,
|
||||
process.env.BROWSER_EXECUTABLE_PATH,
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
for (const candidate of envCandidates) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of await collectExecutableCandidates()) {
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function collectExecutableCandidates(): Promise<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
for (const command of pathCommandsForPlatform()) {
|
||||
const resolved = await findExecutableInPath(command);
|
||||
if (resolved) {
|
||||
candidates.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of commonExecutablePathsForPlatform()) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function pathCommandsForPlatform(): string[] {
|
||||
if (process.platform === "win32") {
|
||||
return ["chrome.exe", "msedge.exe", "brave.exe"];
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return ["google-chrome", "chromium", "msedge", "brave-browser", "brave"];
|
||||
}
|
||||
return [
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"msedge",
|
||||
"brave-browser",
|
||||
"brave",
|
||||
];
|
||||
}
|
||||
|
||||
function commonExecutablePathsForPlatform(): string[] {
|
||||
if (process.platform === "darwin") {
|
||||
return [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
];
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? "";
|
||||
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
||||
return [
|
||||
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
path.join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
path.join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/msedge",
|
||||
"/usr/bin/brave-browser",
|
||||
"/snap/bin/chromium",
|
||||
];
|
||||
}
|
||||
|
||||
async function findExecutableInPath(command: string): Promise<string | undefined> {
|
||||
const pathValue = process.env.PATH;
|
||||
if (!pathValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const directory of pathValue.split(path.delimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(directory, command);
|
||||
if (await isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function assertExecutable(candidate: string, label: string): Promise<void> {
|
||||
if (!(await isExecutable(candidate))) {
|
||||
throw new Error(`${label} not found or not executable: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function isExecutable(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
32
extensions/diffs/src/config.test.ts
Normal file
32
extensions/diffs/src/config.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffsPluginDefaults } from "./config.js";
|
||||
|
||||
describe("resolveDiffsPluginDefaults", () => {
|
||||
it("returns built-in defaults when config is missing", () => {
|
||||
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
||||
});
|
||||
|
||||
it("applies configured defaults from plugin config", () => {
|
||||
expect(
|
||||
resolveDiffsPluginDefaults({
|
||||
defaults: {
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 17,
|
||||
layout: "split",
|
||||
wordWrap: false,
|
||||
background: false,
|
||||
theme: "light",
|
||||
mode: "view",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 17,
|
||||
layout: "split",
|
||||
wordWrap: false,
|
||||
background: false,
|
||||
theme: "light",
|
||||
mode: "view",
|
||||
});
|
||||
});
|
||||
});
|
||||
147
extensions/diffs/src/config.ts
Normal file
147
extensions/diffs/src/config.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
DIFF_LAYOUTS,
|
||||
DIFF_MODES,
|
||||
DIFF_THEMES,
|
||||
type DiffLayout,
|
||||
type DiffMode,
|
||||
type DiffPresentationDefaults,
|
||||
type DiffTheme,
|
||||
type DiffToolDefaults,
|
||||
} from "./types.js";
|
||||
|
||||
type DiffsPluginConfig = {
|
||||
defaults?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
layout?: DiffLayout;
|
||||
wordWrap?: boolean;
|
||||
background?: boolean;
|
||||
theme?: DiffTheme;
|
||||
mode?: DiffMode;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
|
||||
fontFamily: "Fira Code",
|
||||
fontSize: 15,
|
||||
layout: "unified",
|
||||
wordWrap: true,
|
||||
background: true,
|
||||
theme: "dark",
|
||||
mode: "both",
|
||||
};
|
||||
|
||||
const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
defaults: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fontFamily: { type: "string", default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily },
|
||||
fontSize: {
|
||||
type: "number",
|
||||
minimum: 10,
|
||||
maximum: 24,
|
||||
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize,
|
||||
},
|
||||
layout: {
|
||||
type: "string",
|
||||
enum: [...DIFF_LAYOUTS],
|
||||
default: DEFAULT_DIFFS_TOOL_DEFAULTS.layout,
|
||||
},
|
||||
wordWrap: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.wordWrap },
|
||||
background: { type: "boolean", default: DEFAULT_DIFFS_TOOL_DEFAULTS.background },
|
||||
theme: {
|
||||
type: "string",
|
||||
enum: [...DIFF_THEMES],
|
||||
default: DEFAULT_DIFFS_TOOL_DEFAULTS.theme,
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: [...DIFF_MODES],
|
||||
default: DEFAULT_DIFFS_TOOL_DEFAULTS.mode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const diffsPluginConfigSchema: OpenClawPluginConfigSchema = {
|
||||
safeParse(value: unknown) {
|
||||
if (value === undefined) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
try {
|
||||
return { success: true, data: resolveDiffsPluginDefaults(value) };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{ path: [], message: error instanceof Error ? error.message : String(error) }],
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
jsonSchema: DIFFS_PLUGIN_CONFIG_JSON_SCHEMA,
|
||||
};
|
||||
|
||||
export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
|
||||
}
|
||||
|
||||
const defaults = (config as DiffsPluginConfig).defaults;
|
||||
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
|
||||
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
|
||||
}
|
||||
|
||||
return {
|
||||
fontFamily: normalizeFontFamily(defaults.fontFamily),
|
||||
fontSize: normalizeFontSize(defaults.fontSize),
|
||||
layout: normalizeLayout(defaults.layout),
|
||||
wordWrap: defaults.wordWrap !== false,
|
||||
background: defaults.background !== false,
|
||||
theme: normalizeTheme(defaults.theme),
|
||||
mode: normalizeMode(defaults.mode),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
|
||||
const { fontFamily, fontSize, layout, wordWrap, background, theme } = defaults;
|
||||
return {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
layout,
|
||||
wordWrap,
|
||||
background,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFontFamily(fontFamily?: string): string {
|
||||
const normalized = fontFamily?.trim();
|
||||
return normalized || DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily;
|
||||
}
|
||||
|
||||
function normalizeFontSize(fontSize?: number): number {
|
||||
if (fontSize === undefined || !Number.isFinite(fontSize)) {
|
||||
return DEFAULT_DIFFS_TOOL_DEFAULTS.fontSize;
|
||||
}
|
||||
const rounded = Math.floor(fontSize);
|
||||
return Math.min(Math.max(rounded, 10), 24);
|
||||
}
|
||||
|
||||
function normalizeLayout(layout?: DiffLayout): DiffLayout {
|
||||
return layout && DIFF_LAYOUTS.includes(layout) ? layout : DEFAULT_DIFFS_TOOL_DEFAULTS.layout;
|
||||
}
|
||||
|
||||
function normalizeTheme(theme?: DiffTheme): DiffTheme {
|
||||
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
|
||||
}
|
||||
|
||||
function normalizeMode(mode?: DiffMode): DiffMode {
|
||||
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
|
||||
}
|
||||
115
extensions/diffs/src/http.test.ts
Normal file
115
extensions/diffs/src/http.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-diffs-http-"));
|
||||
store = new DiffArtifactStore({ rootDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
} as IncomingMessage,
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user