Merge branch 'main' into fix/sandbox-bind-conflict

This commit is contained in:
chenyangjun-xy
2026-07-03 16:50:11 +08:00
committed by GitHub
1384 changed files with 340317 additions and 14165 deletions
-37
View File
@@ -1,37 +0,0 @@
# Telegram Maintainer Decisions
Use this page during Telegram PR review. These are intentional maintainer decisions, not incidental implementation details.
Verified against Telegram Bot API 10.0, May 8 2026.
## Streaming
- Do not reintroduce `sendMessageDraft` for answer streaming. Telegram drafts are ephemeral 30-second previews in private chats; final delivery still requires a separate `sendMessage`. OpenClaw uses `sendMessage` plus `editMessageText`, then finalizes in place so the user sees one persistent answer.
- Streaming owns one visible preview message. Edit it forward. Do not send an extra final bubble unless the final edit genuinely failed.
- Keep the first-preview debounce. If a provider sends token-sized deltas, coalesce them into cumulative preview text instead of removing the debounce.
- Respect Telegram limits in the Telegram layer. Text over 4096 chars chains into continuation messages. Polls keep the current Bot API 12-option cap.
## Telegram API Ownership
- Prefer grammY primitives and Telegram-native helpers when they model the behavior directly. Avoid custom Bot API wrappers for behavior grammY already owns.
- Throttling is bot-token scoped. All Telegram API clients for the same token share one grammY `apiThrottler()` instance.
- Do not silently retry failed topic sends without topic metadata. A wrong-surface success is worse than a loud Telegram error.
- DM topics and forum topics are distinct. `direct_messages_topic_id` and `message_thread_id` are not interchangeable.
## Context And Authorization
- Reply context comes from OpenClaw-observed messages. Bot API updates expose `reply_to_message`, but there is no arbitrary `getMessage(chat, id)` hydration path later.
- Current local chat context must outrank stale reply ancestry in the prompt. Old replied-to messages should not look like the active conversation.
- Pairing is DM-only. Group and topic authorization need explicit config allowlists.
- Telegram allowlists use numeric sender IDs. Usernames are optional, mutable, and not a reliable arbitrary-user lookup key in the Bot API.
- Group and channel visible replies are policy-controlled. Normal room replies stay private unless `messages.groupChat.visibleReplies: "automatic"` is set or the agent explicitly calls `message.send`.
## Interactive Surfaces
- Native callbacks stay structured. Approval, native command, plugin, select, and multiselect callbacks must not fall through as raw callback text.
- Preserve callback values exactly, including delimiters such as `env|prod`.
- Native slash commands should remain fast-pathable before full workspace and agent-turn setup.
## Review Standard
Telegram behavior PRs need real Telegram proof when they touch transport, streaming, topics, callbacks, authorization, or reply context. Prefer the bot-to-bot QA lane or an equivalent live Telegram probe over synthetic-only validation.
+5 -4
View File
@@ -29,7 +29,7 @@ Use when:
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex, Claude, and cursor-agent filter tool/file chatter, other engines pass raw output through.
- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish.
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
@@ -226,15 +226,16 @@ The helper:
- accepts `--mode uncommitted` as an alias for `--mode local`
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
- supports `--engine codex`, `claude`, `droid`, `copilot`, and `cursor-agent`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
- resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit relative `--*-bin` paths are resolved from the reviewed repository root
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
- supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex, Claude, and cursor-agent hide tool/file event details, emit compact activity summaries, and report usage at turn completion
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output; cursor-agent is run through headless `--print` in ask mode with sandboxing enabled from a helper-owned temporary workspace
- rejects `--no-web-search` for cursor-agent because the Cursor CLI does not expose a CLI-level web-search disable switch
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
- exits nonzero when accepted/actionable findings are present
+109 -6
View File
@@ -17,12 +17,13 @@ from pathlib import Path
from typing import Any, Callable
ENGINES = ("codex", "claude", "droid", "copilot")
ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent")
THINKING_LEVELS_BY_ENGINE = {
"codex": {"low", "medium", "high", "xhigh"},
"claude": {"low", "medium", "high", "xhigh", "max"},
"droid": set(),
"copilot": set(),
"cursor-agent": set(),
}
@@ -480,7 +481,7 @@ def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, e
{json.dumps(SCHEMA, indent=2)}
- Do not modify files.
- Do not invoke nested reviewers or review tools.
- Forbidden nested review commands include: codex review, autoreview, claude review, oracle review.
- Forbidden nested review commands include: codex review, autoreview, claude review, cursor-agent, oracle review.
- You may use read-only tools and web search to inspect files, dependency contracts, upstream docs, current behavior, and security implications.
- Shell commands, if available, must be read-only inspection commands. Do not run tests, formatters, package installs, generators, network mutation commands, git mutation commands, or commands that write files.
- Report only actionable defects introduced or exposed by this change.
@@ -624,7 +625,11 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
raise SystemExit("--thinking is not supported by the copilot engine")
if not args.tools:
raise SystemExit("--no-tools is not supported by the copilot engine; copilot requires a read-only file view tool to load the review bundle without exposing it in argv")
with tempfile.TemporaryDirectory(prefix="autoreview-copilot.") as tempdir:
# ignore_cleanup_errors: on Windows the spawned copilot process (and its MCP
# subprocesses) keep `tempdir` as their cwd briefly after exit, holding a directory
# handle that makes rmtree fail with WinError 32. The review already completed, so a
# cleanup race must not abort the run; best-effort delete is correct here.
with tempfile.TemporaryDirectory(prefix="autoreview-copilot.", ignore_cleanup_errors=True) as tempdir:
prompt_path = Path(tempdir) / "prompt.txt"
prompt_path.write_text(prompt)
os.chmod(prompt_path, 0o600)
@@ -660,6 +665,44 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
return result.stdout
def run_cursor_agent(args: argparse.Namespace, repo: Path, prompt: str) -> str:
if args.thinking:
raise SystemExit("--thinking is not supported by the cursor-agent engine")
if not args.tools:
raise SystemExit("--no-tools is not supported by the cursor-agent engine; use --engine claude --no-tools for a no-tools run")
if not args.web_search:
raise SystemExit("--no-web-search is not supported by the cursor-agent engine; use an engine with a CLI-level web-search disable switch")
with tempfile.TemporaryDirectory(prefix="autoreview-cursor-agent.") as tempdir:
# Trust only the helper-owned empty workspace, never the reviewed repo.
# Cursor may load trusted project hooks/config before model instructions apply.
cmd = [
resolve_command(args.cursor_agent_bin, repo),
"--print",
"--output-format",
"stream-json" if args.stream_engine_output else "json",
"--trust",
"--workspace",
tempdir,
"--mode",
"ask",
"--sandbox",
"enabled",
]
if args.model:
cmd.extend(["--model", args.model])
result = run_with_heartbeat(
cmd,
Path(tempdir),
input_text=prompt,
label="cursor-agent",
stream_output=args.stream_engine_output,
stream_display=CursorAgentStreamDisplay() if args.stream_engine_output else None,
)
if result.returncode != 0:
raise SystemExit(f"cursor-agent engine failed ({result.returncode})\n{result.stderr or result.stdout}")
return result.stdout
class CodexStreamDisplay:
def __init__(self, *, activity_seconds: int = 20) -> None:
self.activity_seconds = activity_seconds
@@ -779,6 +822,41 @@ class ClaudeStreamDisplay:
return text
class CursorAgentStreamDisplay(ClaudeStreamDisplay):
def __call__(self, name: str, line: str) -> str | None:
if name != "stdout":
return line
try:
event = json.loads(line)
except json.JSONDecodeError:
return self.visible(line)
event_type = event.get("type")
if event_type == "system":
return self.visible(f"cursor-agent session: {event.get('session_id', '<unknown>')}\n")
if event_type == "assistant":
return self.assistant_message(event)
if event_type == "result":
return self.visible(self.flush_hidden() + self.result_summary(event))
return self.hidden_activity()
def result_summary(self, event: dict[str, Any]) -> str:
usage = event.get("usage")
fields: list[str] = []
if isinstance(usage, dict):
for key in ("inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens"):
value = usage.get(key)
if isinstance(value, int):
fields.append(f"{key}={value}")
return "cursor-agent usage: " + " ".join(fields) + "\n" if fields else "cursor-agent turn completed\n"
def flush_hidden(self) -> str:
if not self.hidden_events:
return ""
count = self.hidden_events
self.hidden_events = 0
return f"cursor-agent activity: {count} hidden tool/status events\n"
def format_codex_usage(usage: dict[str, Any]) -> str:
fields = [
"input_tokens",
@@ -868,13 +946,35 @@ def parse_json_candidate(text: str) -> Any | None:
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
return None
return parse_embedded_json_object(stripped)
if isinstance(parsed, str) and parsed != text:
nested = parse_json_candidate(parsed)
return nested if nested is not None else parsed
return parsed
def parse_embedded_json_object(text: str) -> Any | None:
decoder = json.JSONDecoder()
candidates: list[Any] = []
for index, char in enumerate(text):
if char not in "[{":
continue
try:
parsed, _end = decoder.raw_decode(text[index:])
except json.JSONDecodeError:
continue
if isinstance(parsed, str):
nested = parse_json_candidate(parsed)
if nested is not None:
candidates.append(nested)
else:
candidates.append(parsed)
for candidate in reversed(candidates):
if isinstance(candidate, dict) and "findings" in candidate:
return candidate
return candidates[-1] if candidates else None
def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str], required: list[str]) -> None:
allowed_top = {"findings", "overall_correctness", "overall_explanation", "overall_confidence"}
extra_top = set(report) - allowed_top
@@ -1016,7 +1116,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude"))
parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid"))
parser.add_argument("--copilot-bin", default=os.environ.get("COPILOT_BIN", "copilot"))
parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex and copilot reject no-tools review.")
parser.add_argument("--cursor-agent-bin", default=os.environ.get("CURSOR_AGENT_BIN", "cursor-agent"))
parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex, copilot, and cursor-agent reject no-tools review.")
parser.add_argument("--no-web-search", dest="web_search", action="store_false", default=True)
parser.add_argument(
"--claude-allowed-tools",
@@ -1034,7 +1135,7 @@ def parse_args() -> argparse.Namespace:
"--stream-engine-output",
action="store_true",
default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1",
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
help="Stream review engine output while preserving buffered output for validation. Codex, Claude, and cursor-agent output is filtered to hide tool/file chatter.",
)
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
parser.add_argument(
@@ -1061,6 +1162,8 @@ def run_engine(args: argparse.Namespace, repo: Path, prompt: str) -> str:
return run_droid(args, repo, prompt)
if args.engine == "copilot":
return run_copilot(args, repo, prompt)
if args.engine == "cursor-agent":
return run_cursor_agent(args, repo, prompt)
raise SystemExit(f"unsupported engine: {args.engine}")
@@ -3,7 +3,7 @@ param(
[ValidateSet('malicious', 'benign')]
[string] $Fixture,
[ValidateSet('codex', 'claude', 'droid', 'copilot')]
[ValidateSet('codex', 'claude', 'droid', 'copilot', 'cursor-agent')]
[string[]] $Engine,
[Alias('h')]
@@ -13,7 +13,7 @@ from collections.abc import Callable
from pathlib import Path
ENGINES = ("codex", "claude", "droid", "copilot")
ENGINES = ("codex", "claude", "droid", "copilot", "cursor-agent")
DEFAULT_ENGINES = ("codex", "claude")
MALICIOUS_INITIAL = """export function uploadPath(name) {
+19 -10
View File
@@ -6,29 +6,38 @@ description: "Use when running QA Lab channel message flow evidence."
# Channel Message Flows
Use this from the OpenClaw repo root to run the QA Lab evidence for Telegram
draft/final delivery sequencing. This skill no longer launches a standalone
script; the behavior is owned by the QA scenario and its Vitest-backed e2e test.
draft/final delivery sequencing. The behavior is owned by one transport-native
QA flow that can run through QA Channel or Crabline Telegram.
## QA Scenario
Run the scenario through QA Lab:
```bash
pnpm openclaw qa suite --scenario channel-message-flows
OPENCLAW_BUILD_PRIVATE_QA=1 node scripts/run-node.mjs qa suite \
--provider-mode mock-openai \
--scenario channel-message-flows \
--channel-driver qa-channel
```
Run the focused e2e test directly in a Codex worktree:
Run the same YAML through the real Telegram plugin against Crabline's local
provider server:
```bash
node scripts/run-vitest.mjs extensions/telegram/src/channel-message-flows.qa.e2e.test.ts
OPENCLAW_BUILD_PRIVATE_QA=1 node scripts/run-node.mjs qa suite \
--provider-mode mock-openai \
--scenario channel-message-flows \
--channel-driver crabline \
--channel telegram
```
## References
- `qa/scenarios/channels/channel-message-flows.yaml`
- `extensions/telegram/src/channel-message-flows.qa.e2e.test.ts`
- `extensions/telegram/src/test-support/channel-message-flows.ts`
- `extensions/qa-channel/src/inbound.ts`
- `extensions/qa-lab/src/qa-transport.ts`
- `extensions/qa-lab/src/crabline-transport.ts`
- `extensions/telegram/src/draft-stream.ts`
The scenario covers `channels.streaming` as primary evidence and records
secondary coverage for thread preservation, delivery ordering, and reasoning
preview visibility.
The scenario covers `channels.streaming` as primary evidence and
`runtime.delivery` as secondary evidence.
@@ -209,7 +209,7 @@ function sectionFor(changelog, version) {
function referencesIn(text) {
const references = [];
for (const match of text.matchAll(
/(?<![A-Za-z0-9_.-])(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<name>[A-Za-z0-9_.-]+))?#(?<number>\d+)/g,
/(?<![A-Za-z0-9_.&-])(?:(?<owner>[A-Za-z0-9_.-]+)\/(?<name>[A-Za-z0-9_.-]+))?#(?<number>\d+)/g,
)) {
const qualifiedRepository = match.groups?.owner
? `${match.groups.owner}/${match.groups.name}`.toLowerCase()
@@ -0,0 +1,61 @@
name: openclaw-codeql-process-exec-boundary-critical-security
disable-default-queries: true
queries:
- uses: security-extended
query-filters:
- include:
precision:
- high
- very-high
tags contain: security
security-severity: /([7-9]|10)\.(\d)+/
paths:
- src/process
- src/tui/tui-local-shell.ts
- src/tui/tui.ts
- src/plugin-sdk/windows-spawn.ts
- packages/agent-core/src/harness/env
- packages/memory-host-sdk/src/host
- extensions/acpx/src
- extensions/bonjour/src/advertiser.ts
- extensions/browser/src/browser/chrome-mcp.ts
- extensions/browser/src/browser/chrome.executables.ts
- extensions/browser/src/browser/chrome.ts
- extensions/codex/src/app-server/sandbox-exec-server
- extensions/codex/src/app-server/transport-stdio.ts
- extensions/codex/src/node-cli-sessions.ts
- extensions/codex-supervisor/src/json-rpc-client.ts
- extensions/file-transfer/src
- extensions/google-meet/src
- extensions/imessage/src
- extensions/memory-core/src/memory/qmd-manager.ts
- extensions/memory-wiki/src/obsidian.ts
- extensions/microsoft-foundry/cli.ts
- extensions/ollama/src/wsl2-crash-loop-check.ts
- extensions/qa-lab/src
- extensions/signal/src/daemon.ts
- extensions/tts-local-cli/speech-provider.ts
- extensions/voice-call/src
- scripts
paths-ignore:
- "**/node_modules"
- "**/coverage"
- "**/*.generated.ts"
- "**/*.bundle.js"
- "**/*-runtime.js"
- "**/*.test.ts"
- "**/*.test.tsx"
- "**/*.spec.ts"
- "**/*.spec.tsx"
- "**/*.e2e.test.ts"
- "**/*.e2e.test.tsx"
- "**/*test-support*"
- "**/*test-helper*"
- "**/*mock*"
- "**/*fixture*"
- "**/*bench*"
@@ -156,7 +156,7 @@ jobs:
- name: Build dist on cache miss
if: steps.dist-cache.outputs.cache-hit != 'true'
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build:ci-artifacts
- name: Build Control UI on cache miss
+55 -5
View File
@@ -95,6 +95,7 @@ jobs:
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_native_i18n: ${{ steps.manifest.outputs.run_native_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
@@ -213,6 +214,7 @@ jobs:
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
@@ -280,6 +282,8 @@ jobs:
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const runControlUiI18n =
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
const runNativeI18n =
parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) && !docsOnly;
const checksFastCoreTasks = [];
if (runNodeFull) {
checksFastCoreTasks.push(
@@ -346,6 +350,7 @@ jobs:
run_check_additional: runNodeFull,
run_check_docs: docsChanged && eventName !== "push",
run_control_ui_i18n: runControlUiI18n,
run_native_i18n: runNativeI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
@@ -806,6 +811,34 @@ jobs:
path: .local/gateway-watch-regression/
retention-days: 7
native-i18n:
permissions:
contents: read
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_native_i18n == 'true' }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Check native app i18n inventory
run: pnpm native:i18n:check
- name: Check Android app i18n resources
run: pnpm android:i18n:check
- name: Check Apple app i18n catalogs
run: pnpm apple:i18n:check
checks-fast-core:
permissions:
contents: read
@@ -1148,8 +1181,6 @@ jobs:
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
;;
shrinkwrap)
pnpm deps:shrinkwrap:check
@@ -1699,12 +1730,25 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: apps/macos/.build
key: ${{ runner.os }}-swift-build-v2-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved', 'apps/macos/Sources/**', 'apps/macos/Tests/**', 'apps/shared/OpenClawKit/Package.swift', 'apps/shared/OpenClawKit/Sources/**', 'apps/swabble/Package.swift', 'apps/swabble/Sources/**') }}
key: ${{ runner.os }}-swift-build-v3-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved', 'apps/macos/Sources/**', 'apps/macos/Tests/**', 'apps/shared/OpenClawKit/Package.swift', 'apps/shared/OpenClawKit/Sources/**', 'apps/swabble/Package.swift', 'apps/swabble/Sources/**') }}
restore-keys: |
${{ runner.os }}-swift-build-v2-${{ steps.swift-toolchain.outputs.key }}-
${{ runner.os }}-swift-build-v3-${{ steps.swift-toolchain.outputs.key }}-
- name: Validate Swift build cache
id: validate-swift-build-cache
run: |
set -euo pipefail
cache_valid=true
sparkle_info="apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/Info.plist"
if [[ -d apps/macos/.build && ! -f "$sparkle_info" ]]; then
echo "::warning::Swift build cache is missing Sparkle; resetting the local SwiftPM build directory."
swift package --package-path apps/macos reset
cache_valid=false
fi
echo "cache-valid=$cache_valid" >> "$GITHUB_OUTPUT"
- name: Preserve Swift build cache hit
if: steps.swift-build-cache.outputs.cache-hit == 'true'
if: steps.swift-build-cache.outputs.cache-hit == 'true' && steps.validate-swift-build-cache.outputs.cache-valid == 'true'
run: |
set -euo pipefail
# Exact source-hash cache hits already match these inputs; checkout
@@ -1738,7 +1782,13 @@ jobs:
if swift build --package-path apps/macos --product OpenClaw --configuration release; then
exit 0
fi
if [[ "$attempt" -eq 3 ]]; then
break
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
# SwiftPM can invalidate a restored binary artifact while planning.
# Reset so the next attempt downloads a complete dependency graph.
swift package --package-path apps/macos reset
sleep $((attempt * 20))
done
exit 1
@@ -446,6 +446,7 @@ jobs:
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '
.[]
| select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex-supervisor/src/json-rpc-client\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)"))
| select(.filename | test("(^|/)[^/]+\\.(?:e2e\\.)?test\\.tsx?$") | not)
| .filename as $file
| (.patch // "")
| split("\n")[]
+26
View File
@@ -17,7 +17,28 @@ on:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "extensions/acpx/src/**"
- "extensions/bonjour/src/advertiser.ts"
- "extensions/browser/src/browser/chrome-mcp.ts"
- "extensions/browser/src/browser/chrome.executables.ts"
- "extensions/browser/src/browser/chrome.ts"
- "extensions/codex/src/app-server/sandbox-exec-server/**"
- "extensions/codex/src/app-server/transport-stdio.ts"
- "extensions/codex/src/node-cli-sessions.ts"
- "extensions/codex-supervisor/src/json-rpc-client.ts"
- "extensions/file-transfer/src/**"
- "extensions/google-meet/src/**"
- "extensions/imessage/src/**"
- "extensions/memory-core/src/memory/qmd-manager.ts"
- "extensions/memory-wiki/src/obsidian.ts"
- "extensions/microsoft-foundry/cli.ts"
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
- "extensions/qa-lab/src/**"
- "extensions/signal/src/daemon.ts"
- "extensions/tts-local-cli/speech-provider.ts"
- "extensions/voice-call/src/**"
- "packages/**"
- "scripts/**"
- "src/**"
push:
branches:
@@ -67,6 +88,11 @@ jobs:
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: process-exec-boundary
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: ubuntu-24.04
@@ -24,7 +24,7 @@ permissions:
concurrency:
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.actor != 'github-actions[bot]' }}
jobs:
plan:
@@ -124,25 +124,81 @@ jobs:
- name: Ensure translation provider secrets exist
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
OPENCLAW_DOCS_I18N_OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
if [ -z "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
exit 1
fi
- name: Refresh control UI locale files
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
OPENCLAW_DOCS_I18N_OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
ANTHROPIC_MODEL: claude-opus-4-8
OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE || 'gpt-5.5' }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "0"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
run: |
set -euo pipefail
run_refresh() {
local provider="$1"
local model="$2"
local openai_api_key="${3-}"
if [ "$provider" = "openai" ]; then
OPENAI_API_KEY="$openai_api_key" \
OPENCLAW_CONTROL_UI_I18N_PROVIDER="$provider" \
OPENCLAW_CONTROL_UI_I18N_MODEL="$model" \
node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
return
fi
OPENCLAW_CONTROL_UI_I18N_PROVIDER="$provider" \
OPENCLAW_CONTROL_UI_I18N_MODEL="$model" \
node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
}
run_openai_refresh() {
local status=1
if [ -n "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ]; then
set +e
run_refresh openai "${OPENAI_MODEL}" "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY}"
status="$?"
set -e
if [ "$status" -eq 0 ]; then
return 0
fi
if [ -z "${OPENAI_API_KEY:-}" ] || [ "${OPENAI_API_KEY}" = "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY}" ]; then
return "$status"
fi
echo "::warning::Docs OpenAI control UI locale refresh key failed for ${LOCALE}; retrying with repository OpenAI key."
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
return "$status"
fi
run_refresh openai "${OPENAI_MODEL}" "${OPENAI_API_KEY}"
}
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
set +e
run_refresh anthropic "${ANTHROPIC_MODEL}"
status="$?"
set -e
if [ "$status" -eq 0 ]; then
exit 0
fi
if [ -z "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ]; then
exit "$status"
fi
echo "::warning::Anthropic control UI locale refresh failed for ${LOCALE}; retrying with OpenAI."
fi
run_openai_refresh
- name: Commit and push locale updates
env:
+1
View File
@@ -102,6 +102,7 @@ jobs:
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
node scripts/ios-write-swift-filelist.mjs
cd apps/ios
xcodegen generate
@@ -0,0 +1,177 @@
name: Native App Locale Refresh
on:
push:
branches:
- main
paths:
- apps/android/app/src/main/**
- apps/ios/**
- apps/macos/Sources/**
- apps/macos/Package.swift
- apps/shared/OpenClawKit/Sources/**
- apps/.i18n/native-source.json
- scripts/control-ui-i18n.ts
- scripts/native-app-i18n.ts
- ui/src/i18n/.i18n/glossary.*.json
- .github/workflows/native-app-locale-refresh.yml
workflow_dispatch:
permissions:
contents: write
concurrency:
group: native-app-locale-refresh-${{ github.event_name == 'push' && github.ref || format('manual-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.actor != 'github-actions[bot]' }}
jobs:
refresh:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
strategy:
fail-fast: false
max-parallel: 2
matrix:
locale:
[
zh-CN,
zh-TW,
pt-BR,
de,
es,
ja-JP,
ko,
fr,
hi,
ar,
it,
tr,
uk,
id,
pl,
th,
vi,
nl,
fa,
ru,
sv,
]
runs-on: ubuntu-latest
name: Refresh native ${{ matrix.locale }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Ensure translation provider secrets exist
env:
OPENCLAW_DOCS_I18N_OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
exit 1
fi
- name: Refresh native locale artifact
env:
OPENCLAW_DOCS_I18N_OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_MODEL: claude-opus-4-8
OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE || 'gpt-5.5' }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "0"
LOCALE: ${{ matrix.locale }}
run: |
set -euo pipefail
run_refresh() {
local provider="$1"
local model="$2"
local openai_api_key="${3-}"
if [ "$provider" = "openai" ]; then
OPENAI_API_KEY="$openai_api_key" \
OPENCLAW_CONTROL_UI_I18N_PROVIDER="$provider" \
OPENCLAW_CONTROL_UI_I18N_MODEL="$model" \
node --import tsx scripts/native-app-i18n.ts sync --write --locale "${LOCALE}"
return
fi
OPENCLAW_CONTROL_UI_I18N_PROVIDER="$provider" \
OPENCLAW_CONTROL_UI_I18N_MODEL="$model" \
node --import tsx scripts/native-app-i18n.ts sync --write --locale "${LOCALE}"
}
run_openai_refresh() {
local status=1
if [ -n "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ]; then
set +e
run_refresh openai "${OPENAI_MODEL}" "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY}"
status="$?"
set -e
if [ "$status" -eq 0 ]; then
return 0
fi
if [ -z "${OPENAI_API_KEY:-}" ] || [ "${OPENAI_API_KEY}" = "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY}" ]; then
return "$status"
fi
echo "::warning::Docs OpenAI native locale refresh key failed for ${LOCALE}; retrying with repository OpenAI key."
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
return "$status"
fi
run_refresh openai "${OPENAI_MODEL}" "${OPENAI_API_KEY}"
}
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
set +e
run_refresh anthropic "${ANTHROPIC_MODEL}"
status="$?"
set -e
if [ "$status" -eq 0 ]; then
exit 0
fi
if [ -z "${OPENCLAW_DOCS_I18N_OPENAI_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ]; then
exit "$status"
fi
echo "::warning::Anthropic native locale refresh failed for ${LOCALE}; retrying with OpenAI."
fi
run_openai_refresh
- name: Commit and push locale artifact
env:
LOCALE: ${{ matrix.locale }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if ! git status --porcelain -- apps/.i18n/native apps/.i18n/native-source.json | grep -q .; then
echo "No native locale changes for ${LOCALE}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A apps/.i18n/native apps/.i18n/native-source.json
git commit --no-verify -m "chore(i18n): refresh native ${LOCALE} locale"
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase --autostash "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push ${LOCALE} native locale update after retries."
exit 1
@@ -9,11 +9,6 @@ permissions:
pull-requests: write
issues: write
env:
# Temporary rollout bridge for PRs opened before this workflow's script landed.
# Remove once the pre-rollout PR set has drained.
OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA: 5d9c010628ea4de3492a12e32f9be5b8c5dfa9ed
concurrency:
group: security-sensitive-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
@@ -24,40 +19,13 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Detect security-sensitive changes
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
@@ -72,40 +40,13 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check security-sensitive guard rollout eligibility
id: rollout
env:
GH_TOKEN: ${{ github.token }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
status="$(
gh api \
"repos/${GITHUB_REPOSITORY}/compare/${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}...${PR_BASE_SHA}" \
--jq '.status'
)"
case "$status" in
ahead|identical)
echo "ready=true" >> "$GITHUB_OUTPUT"
;;
behind|diverged)
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping security-sensitive guard for a PR base that predates rollout commit ${OPENCLAW_SECURITY_SENSITIVE_GUARD_ROLLOUT_SHA}."
;;
*)
echo "Unexpected compare status for security-sensitive guard rollout: $status" >&2
exit 1
;;
esac
- name: Check out trusted base workflow scripts
if: steps.rollout.outputs.ready == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Enforce security-sensitive guard
if: steps.rollout.outputs.ready == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
+1
View File
@@ -81,6 +81,7 @@ apps/android/fastlane/README.md
apps/ios/fastlane/report.xml
apps/ios/fastlane/Preview.html
apps/ios/fastlane/screenshots/
apps/ios/fastlane/metadata/*/release_notes.txt
apps/ios/fastlane/test_output/
apps/ios/fastlane/logs/
apps/ios/fastlane/.env
+251 -2
View File
@@ -2,11 +2,260 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.7.1
### Highlights
- **OpenAI GPT-5.6 support:** OpenClaw now recognizes the GPT-5.6 model family across catalog, capability, and runtime selection paths. (#98333) Thanks @steipete-oai.
- **External harness attachment:** `openclaw attach` launches an external harness against an existing Gateway session, making interactive Codex-style workflows easier to resume and inspect. (#96454) Thanks @anagnorisis2peripeteia and @obviyus.
- **Telegram Codex workflows:** Telegram can now start Codex pairing with `/login`, steer active Codex runs, and recover final replies across transient API failures. (#98006, #98126, #98786) Thanks @100yenadmin, @Kyzcreig, and @obviyus.
- **Event-driven cron runs:** the new `on-exit` schedule kind wakes an agent when a watched command exits, while session-targeted runs can detach cleanly. (#92037, #98755) Thanks @anagnorisis2peripeteia, @obviyus, and @EthanSK.
- **Native app refresh:** iOS adopts the iOS 26 visual system with clearer Chat, Talk, and onboarding flows, while native app localization expands across Apple and Android surfaces. (#98452, #98736, #97110, #97111, #97112, #97113) Thanks @vincentkoc.
- **Richer messaging:** iMessage gains native poll creation, reading, and voting, and built-in usage footers provide clearer per-turn accounting in chat. (#98421, #92657, #92877) Thanks @omarshahine, @lobster, and @Marvinthebored.
- **Safer scoped conversations:** capability profiles prepare per-conversation tool and access boundaries without weakening the existing default profile. (#98536)
### Changes
- **Model and provider coverage:** add GPT-5.6 support, use Nemotron Super's 1M context window, and preserve explicit OpenRouter authentication headers. (#98333, #98726, #98187) Thanks @steipete-oai, @eleqtrizit, @sunlit-deng, and @laurencebrown.
- **CLI and node workflows:** add `openclaw attach`, node context-path support, actionable device-approval recovery guidance, and clearer plugin install exit diagnostics. (#96454, #97679, #98115, #98146, #98497) Thanks @anagnorisis2peripeteia, @obviyus, @wm0018, @welfo-beo, @RomneyDa, @Sanjays2402, and @vincentkoc.
- **Cron and usage:** add exit-triggered schedules, detached session-targeted runs, an in-flight job doctor warning, and a built-in full usage footer. (#92037, #98755, #98620, #92657, #92877) Thanks @anagnorisis2peripeteia, @obviyus, @EthanSK, @masatohoshino, and @Marvinthebored.
- **Native apps and localization:** modernize iOS presentation and Talk controls, add Gateway speech providers, improve QR onboarding and protocol recovery, localize core Apple and Android surfaces, and add Swedish mobile localization. (#98452, #98736, #98376, #98302, #98385, #97110, #97111, #97112, #97113, #98043) Thanks @Tony-ooo, @joelnishanth, @cursoragent, @joshavant, @vincentkoc, and @yeager.
- **Messaging capabilities:** add native iMessage polls and Telegram Codex pairing and steering flows. (#98421, #98006, #98126) Thanks @omarshahine, @lobster, @100yenadmin, and @Kyzcreig.
- **Doctor and diagnostics:** expose auth-profile, workspace, device-pairing, channel-plugin, memory-provider, systemd exhaustion, and Windows LAN firewall findings. (#97125, #97358, #97366, #97496, #97968, #98291, #98666) Thanks @giodl73-repo, @masatohoshino, and @joshavant.
- **Conversation and review controls:** prepare scoped conversation capability profiles and add Cursor Agent as an autoreview engine. (#98536, #97348) Thanks @hxy91819.
### Fixes
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
- **Telegram durability:** recover stalled ingress claims, retry restart-dropped media, survive transient polling errors, dead-letter poison updates, preserve forwarded rich text, route plugin callbacks correctly, and fall back safely when rich final replies are rejected. (#97118, #98102, #98735, #98775, #98776, #97174, #98786) Thanks @vincentkoc, @luoyanglang, @DaveArcher18, @obviyus, and @goldmar.
- **Agent and context reliability:** preserve runtime overrides and steered subagent tasks, improve harness-aware context estimation and compaction prechecks, time out silent local streams, recover mid-stream failures, and cap Gateway run-cache growth. (#92237, #77539, #97928, #97861, #98525, #95430, #77973) Thanks @sercada, @amittell, @liuhao1024, @yetval, @osolmaz, @lzyyzznl, @vincentkoc, @alexelgier, and @fede-kamel.
- **Provider and network safety:** bound oversized or malformed responses across Moonshot, MiniMax, Anthropic OAuth, Discord, Matrix, SMS, browser, update, embeddings, Tlön, and Inworld paths. (#96502, #96322, #96644, #97693, #97662, #97999, #98455, #98508, #98554, #98496, #98660) Thanks @hugenshen, @cursoragent, @lsr911, @solodmd, @Alix-007, @wings1029, @lzyyzznl, @sunlit-deng, @vincentkoc, and @Pandah97.
- **Channel delivery and routing:** keep Slack replies in the active thread, preserve account-bound delivery routes, apply response prefixes, suppress internal traces and unwanted fallback replies, and retain WeChat session routing for opaque account ids. (#97168, #98240, #89949, #93639, #97989, #80928, #93686) Thanks @LiuwqGit, @gorkem2020, @yetval, @wangwllu, @ZengWen-DT, @alexuser, @UnClouded77, @zhangguiping-xydt, and @htkillermax-gif.
- **Cron correctness:** preserve provider and model selections on timeouts, retain startup catch-up deferrals, keep action-required output, clear blank thinking overrides, and preserve provider-owned daily-reset sessions. (#95943, #94022, #93810, #96393, #96293, #98356) Thanks @ZengWen-DT, @cursoragent, @luke-renjoy, @RichChen01, @vincentkoc, @yetval, @snowzlmbot, @nz365guy, and @takamasa-aiso.
- **Memory and session recovery:** detect unindexed transcripts, preserve notes through transient reads, avoid cross-directory resumes, disambiguate reserved wiki index pages, and skip empty QMD sync work. (#97857, #98360, #97785, #94326, #90030) Thanks @zw-xysk, @CHE10X, @qingminglong, @yetval, @vincentkoc, @sahibzada-allahyar, and @ruben2000de.
- **Windows and execution:** bind allowlisted execution to the validated Windows path, propagate `PATHEXT`, normalize inbound paths case-insensitively, and prevent cleanup crashes on Windows. (#98260, #98093, #97630, #97901) Thanks @eleqtrizit, @wendy-chsy, @VectorPeak, and @paulcam206.
- **Mobile and UI stability:** preserve iOS chat line breaks and final replies, improve Android pairing and TLS recovery, hide expired pairing cards, and keep workspace file rails scrollable. (#98304, #98117, #98366, #98439, #98483, #98049, #98646, #98611) Thanks @joshavant, @Jabato01, @ooiuuii, @wuqxuan, @645648406-max, and @zw-xysk.
- **Codex and approval flows:** report ChatGPT authentication correctly, rename destructive approval mode to `ask`, classify dynamic goal and session tool results accurately, and derive terminal-idle timeouts from the explicit run deadline. (#91240, #98501, #98659, #96856, #85296) Thanks @849261680, @ukstem, @kevinslin, @yetval, @nxmxbbd, @alkor2000, and @vincentkoc.
- **Configuration and plugin health:** surface unloadable channel plugins, preserve defaulted provider base URLs during patches, validate bundled plugin updates by manifest contract, and retain legacy ClawHub families where required. (#96397, #98396, #98010, #98249) Thanks @849261680, @momothemage, @weltmaister, @LiLan0125, @herove, and @Patrick-Erichsen.
- **QQBot media delivery:** scope sandbox-generated media sends to the active session's workspace so `/workspace/...` and relative generated-file paths resolve safely across QQBot media tags, structured payloads, and streaming delivery. (#92872) Thanks @zhangguiping-xydt.
### Complete contribution record
This audited record covers the complete 66e676d29b92d040716376a75aca32bad655cfac..3e50f41dd6ea3446b5c98a2f19ec70982ac908e6 history: 212 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #92872** fix(qqbot): allow scoped sandbox media sends. Thanks @zhangguiping-xydt.
- **PR #96502** fix(moonshot): bound video description JSON response reads. Thanks @hugenshen and @cursoragent.
- **PR #98249** Preserve legacy ClawHub family for selected plugins. Thanks @Patrick-Erichsen.
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1.
- **PR #93820** fix(imessage): recognize MiniMax mm: reasoning tags in reflection guard (completes #93767). Thanks @Alix-007.
- **PR #94096** fix(usage): reject inverted startDate-endDate range in usage.cost and sessions.usage. Thanks @Alix-007.
- **PR #97125** Doctor: expose auth profile findings. Thanks @giodl73-repo.
- **PR #98256** fix(mcp): require owner for Claude permission replies. Thanks @eleqtrizit.
- **PR #98142** fix(cli): stop `pairing list` crashing with empty channel enum. Thanks @RomneyDa.
- **PR #98260** fix(exec): bind Windows allowlist execution path. Thanks @eleqtrizit.
- **PR #97118** fix(telegram): recover stalled ingress spool claims. Thanks @vincentkoc.
- **PR #97168** fix(slack): prefer current thread session for inherited outbound replies. Related #96535. Thanks @LiuwqGit and @gorkem2020.
- **PR #97769** fix(plugins): apply output text transforms to toolcall_delta and toolcall_end events. Related #97761. Thanks @ZOOWH and @get-viti.
- **PR #96544** fix(doctor): merge colliding model-ref map keys instead of dropping. Thanks @yetval and @vincentkoc.
- **PR #97177** fix(memory-wiki): gracefully handle unparsable YAML frontmatter in vault scans (#96125). Thanks @SunnyShu0925 and @cow11023.
- **PR #97167** fix #96840: [Bug]: Targetless message.send fails with 'Action send requires a target' in WebChat despite docs stating source-reply sink should handle it. Thanks @zhangguiping-xydt and @MantisCartography.
- **PR #98302** fix(ios): advance onboarding step after QR scan. Related #98297. Thanks @joelnishanth and @cursoragent.
- **PR #96644** fix(anthropic-oauth): bound OAuth token endpoint response reads. Thanks @solodmd.
- **PR #96397** fix: warn when configured channel plugins cannot load. Thanks @849261680.
- **PR #96359** test: migrate src/commands tests to shared temp dir helpers. Thanks @xialonglee.
- **PR #96293** fix(cron): clear agentTurn thinking override by blanking the field. Related #96287. Thanks @ZengWen-DT and @takamasa-aiso.
- **PR #96058** test: prefer shared temp dir helpers in auto-reply and install-fallback tests. Thanks @xialonglee.
- **PR #87298** test: add temp directory helper guidance. Thanks @hxy91819.
- **PR #97785** fix(sessions): avoid cross-cwd recent resumes. Related #96542. Thanks @qingminglong and @yetval.
- **PR #97698** fix(pdf): reject empty parsed page ranges before native analysis. Thanks @zhangguiping-xydt.
- **PR #97693** fix(discord): bound requestDiscord happy-path response reads to prevent OOM. Thanks @Alix-007.
- **PR #97683** fix(irc): guard surrogate-range codepoints in \u literal-escape decoder. Thanks @llagy009.
- **PR #96938** fix(utils): keep reply directive ids unicode-safe. Thanks @ly-wang19.
- **PR #97857** fix(memory): detect unindexed session transcripts in status mode (fixes #97814). Thanks @zw-xysk and @CHE10X.
- **PR #98094** fix(android): clarify gateway auth recovery states. Thanks @qingminglong.
- **PR #98205** test(gateway): add unit tests for node wake state tracking and testing seam. Thanks @zenglingbiao.
- **PR #98115** fix: surface node approval guidance from devices CLI. Thanks @welfo-beo.
- **PR #97898** docs: clarify source checkout Node floor. Related #97792. Thanks @lin-hongkuan and @aniruddhaadak80.
- **PR #94526** test(telegram): add regression test for forum topic message_thread_id with streamed reasoning. Related #89352. Thanks @xialonglee and @pmika.
- **PR #98145** fix(device-pairing): don't churn requestId on subset re-requests. Thanks @RomneyDa.
- **PR #98267** fix(system-prompt): move exec-approval + Authorized Senders below cache boundary. Related #98261. Thanks @headbouyJB.
- **PR #98304** fix: preserve iOS chat line breaks. Related #98028. Thanks @joshavant and @Jabato01.
- **PR #98187** fix(openrouter): send explicit auth headers. Related #97934. Thanks @sunlit-deng and @laurencebrown.
- **PR #95708** fix: show WebChat preamble progress during tool activity. Thanks @ragesaq.
- **PR #98210** fix(gateway): iOS Talk treats SecretRef-backed API keys as missing. Related #98209. Thanks @ooiuuii.
- **PR #98009** test(infra): add unit tests for SQLite number normalization. Thanks @dwc1997.
- **PR #98087** test(config): add unit tests for resolveExecCommandHighlighting. Thanks @solodmd.
- **PR #98219** test(utils): add unit tests for chunkItems. Thanks @zenglingbiao.
- **PR #98093** fix(core): propagate caller env PATHEXT through isExecutableFile on Windows. Thanks @wendy-chsy.
- **PR #97973** fix(matrix): guard JSON.parse against malformed homeserver response bodies. Thanks @lsr911.
- **PR #97999** fix(sms): guard Twilio JSON.parse against malformed API response bodies. Thanks @lsr911.
- **PR #98043** Add Swedish mobile app localization. Thanks @yeager.
- **PR #98144** fix(tui): correct disconnect copy for device scope upgrades. Thanks @RomneyDa.
- **PR #98240** fix(agents): keep merged delivery routes account-bound. Thanks @yetval.
- **PR #89949** fix(media): pin requester delivery route when task starts. Thanks @wangwllu.
- **PR #98226** Redact bare Fireworks API keys. Related #98225. Thanks @ooiuuii.
- **PR #98319** docs: publish release notes for v2026.6.11. Thanks @hannesrudolph.
- **PR #98257** fix: show in-progress status for channel runs. Thanks @scotthuang.
- **PR #97931** fix(gateway): keep provider-owned CLI sessions across the daily default reset. Thanks @yetval.
- **PR #98325** docs: refresh docs map for v2026.6.11. Thanks @hannesrudolph.
- **PR #97929** fix(auto-reply): stop level directives from eating the next message word. Thanks @yetval.
- **PR #97928** fix(agents): estimate harness role sizes in context guard char estimator (fixes #97927). Thanks @liuhao1024 and @yetval.
- **PR #97861** fix(compaction): count bashExecution and summary turns in pre-prompt overflow precheck. Thanks @yetval.
- **PR #97137** doctor: add memory search lint findings. Thanks @giodl73-repo.
- **PR #97358** Doctor: expose workspace status findings. Thanks @giodl73-repo.
- **PR #95622** test(qa-lab): harden whatsapp qa scenarios. Thanks @mcaxtr.
- **PR #98346** fix: prevent skill-creator from bypassing workshop proposals. Related #96054. Thanks @momothemage and @xianshishan.
- **PR #98169** fix(heartbeat): scope commitment fan-out prompts. Thanks @bdjben.
- **PR #97366** Doctor: expose device pairing findings. Thanks @giodl73-repo.
- **PR #98366** fix: Android TLS fingerprint verification times out on slow handshakes. Related #98365. Thanks @joshavant.
- **PR #98353** fix(ios): open app on Chat by default. Thanks @BsnizND.
- **PR #98352** fix(security): warn on agent skill MCP boundary drift. Thanks @momothemage.
- **PR #98347** fix: retry image describe fallback models. Thanks @momothemage.
- **PR #98117** fix(ios): avoid transient duplicate final replies. Related #98116. Thanks @ooiuuii and @joshavant.
- **PR #98293** fix(gateway): emit stale exec approval followup diagnostics. Thanks @BsnizND.
- **PR #98376** fix(ios): use Gateway speech providers in Talk. Related #98153. Thanks @Tony-ooo.
- **PR #66685** Suppress expired exec approval followup warnings. Thanks @pfrederiksen.
- **PR #98385** fix: show actionable mobile protocol mismatch recovery. Related #98384. Thanks @joshavant.
- **PR #98146** fix(cli): explain how to recover from device approve deadlock. Thanks @RomneyDa.
- **PR #98423** improve(ios): clarify Control and Talk visual hierarchy. Related #98397.
- **PR #98217** fix(doctor): recover legacy cron archive across devices. Thanks @masatohoshino.
- **PR #98333** feat(openai): add GPT-5.6 series support. Related #98296. Thanks @steipete-oai.
- **PR #96393** fix(cron): preserve action-required command output. Related #96346. Thanks @snowzlmbot and @nz365guy.
- **PR #98429** fix(ios): classify TLS fingerprint timeouts. Thanks @joshavant.
- **PR #98439** fix: Android setup codes accept local mDNS gateway hosts. Thanks @joshavant.
- **PR #98443** fix(ios): improve light and dark appearance contrast. Related #98440.
- **PR #97742** fix(llm): preserve structured tool result text across providers. Thanks @snowzlmbot.
- **PR #97968** fix(status): surface unregistered memory embedding providers. Thanks @masatohoshino.
- **PR #92237** fix(agents): preserve runtime settings overrides [AI-assisted]. Thanks @sercada.
- **PR #95888** fix(active-memory): caveat mutable ops facts; mark truncated recall as incomplete. Thanks @spencer2211.
- **PR #98291** fix(gateway): surface systemd start-limit exhaustion. Thanks @masatohoshino.
- **PR #90517** fix(gateway): hint missing external plugin for web login. Related #83277. Thanks @TUARAN and @carol-iung.
- **PR #98369** test(infra): add unit tests for SQLite user_version pragma helper. Thanks @dwc1997.
- **PR #98340** fix: extension api.exec leaves child processes after timeout. Related #98335. Thanks @ooiuuii.
- **PR #92063** fix(ui): collapse duplicate assistant groups during segmented streaming. Related #63956. Thanks @harjothkhara and @contentfree.
- **PR #98354** fix(infra): guard delivery queue inflate against corrupted entry_json. Thanks @Pick-cat.
- **PR #90566** fix(agents): warn on cron announce skip. Related #68561. Thanks @sahibzada-allahyar and @Mibslee.
- **PR #98371** fix(ports): validate lsof PID parsing before assignment. Thanks @lzyyzznl.
- **PR #98356** fix(cron): keep provider-owned CLI sessions across the daily default reset. Thanks @yetval.
- **PR #98395** test(shared): add unit tests for account enabled guard. Thanks @dwc1997.
- **PR #98411** fix(agents): recover thinking errors from provider body. Related #98308. Thanks @sunlit-deng and @clearhorizoninvestments.
- **PR #98494** docs(skills): support variable landable sweep batches. Thanks @vincentkoc.
- **PR #91240** fix: report Codex ChatGPT status auth. Related #91099. Thanks @849261680 and @ukstem.
- **PR #98370** test(agents): add unit tests for thinking block detection. Thanks @dwc1997.
- **PR #96711** test: prefer shared temp dir helpers in config, gateway, cron, crestodian, and state tests. Thanks @xialonglee.
- **PR #98483** fix: Android QR scan starts gateway pairing. Thanks @joshavant.
- **PR #95230** fix docs-list-mdx-pages. Thanks @hugenshen.
- **PR #96322** fix(minimax): bound JSON response reads to prevent OOM. Thanks @lsr911.
- **PR #95348** fix config-chmod-warning. Thanks @hugenshen and @cursoragent.
- **PR #95229** fix(copilot): guard against undefined runtime.state during cli-metadata registration. Related #94516. Thanks @sunlit-deng and @cuihaijun.
- **PR #94636** fix(memory): skip raw snippets during promotion. Thanks @tayoun.
- **PR #94013** [AI] fix(feishu): guard partial channelRuntime in monitor startup. Thanks @xydt-tanshanshan.
- **PR #93466** [AI] fix(feishu): guard against missing inbound in channelRuntime fallback. Thanks @xydt-tanshanshan.
- **PR #98049** fix: hide expired pairing QR cards in Control UI. Related #98039. Thanks @ooiuuii.
- **PR #96094** fix(memory): prove live manager recovery after CLI reindex. Related #91167. Thanks @849261680 and @kiagentkronos-cell.
- **PR #98482** fix: advertise route-aware LAN Control UI links. Thanks @joshavant.
- **PR #71537** Recover archived (.reset) session transcripts in memory hook + session-logs skill. Thanks @injinj.
- **PR #96375** docs(config-agents): correct built-in alias table for opus and gpt. Thanks @niks999.
- **PR #98453** docs(gateway): fix Telegram streaming default in config-channels.md. Thanks @solodmd.
- **PR #98533** fix: repair hosted CI baseline assertions.
- **PR #98421** feat(imessage): native poll support — create, read, vote. Thanks @omarshahine and @lobster.
- **PR #98318** docs(matrix): document missing streaming.progress mode, progress sub-fields, and mentionPatterns config. Thanks @wm0018 and @vincentkoc.
- **PR #97753** docs(onboard): document 11 missing non-interactive CLI flags. Thanks @wm0018 and @vincentkoc.
- **PR #97851** fix(mattermost): bound null-body error response reads. Thanks @Pick-cat.
- **PR #98360** fix(memory-wiki): preserve notes after transient page reads. Related #98345. Thanks @qingminglong and @yetval.
- **PR #98551** test: fix stale core test type failures. Thanks @RomneyDa.
- **PR #98455** fix(browser): bound error body read in fetchHttpJson to prevent OOM. Thanks @wings1029.
- **PR #95906** fix(code-mode): surface QuickJS error name and message to the model. Thanks @ZengWen-DT and @vincentkoc.
- **PR #97901** fix(agents): stop copilot autoreview cleanup crash on Windows. Thanks @paulcam206.
- **PR #97923** fix(slack): truncate served arg-menu option labels on a surrogate boundary. Thanks @LEXES7.
- **PR #98010** fix(update): validate bundle plugin payloads by manifest contract. Related #97985. Thanks @LiLan0125 and @herove.
- **PR #85296** fix(codex): derive terminal-idle watchdog from explicit run timeout. Thanks @alkor2000 and @vincentkoc.
- **PR #97110** feat(i18n): add native app locale inventory. Thanks @vincentkoc.
- **PR #98396** fix: allow config.patch with defaulted provider baseUrl. Related #98270. Thanks @momothemage and @weltmaister.
- **PR #98503** fix(usage-bar): use Object.hasOwn instead of in operator to avoid prototype chain pollution. Related #98466. Thanks @chenyangjun-xy and @zhangLei99586.
- **PR #97111** feat(android): localize core gateway surfaces. Thanks @vincentkoc.
- **PR #97630** fix(media): normalize Windows inbound paths case-insensitively. Thanks @VectorPeak.
- **PR #82638** fix(agents): skip implicit provider discovery when models.mode is 'replace' [AI-assisted]. Related #66957. Thanks @eldar702 and @wangzhengshu.
- **PR #87917** fix sessions json lineage metadata. Related #80286. Thanks @zhangguiping-xydt and @islandpreneur007.
- **PR #93639** fix(message-tool): apply messages.responsePrefix to outbound sends. Thanks @ZengWen-DT.
- **PR #94440** fix: #94432 classify Cloudflare challenge 403 as upstream_html instead of auth_html. Thanks @lzyyzznl and @pbm9z95m6z-hue.
- **PR #98119** fix: reduce Docker build memory pressure. Related #98118. Thanks @zyzo.
- **PR #97679** feat(node): add --context-path flag to node run/install for reverse-p…. Related #97678. Thanks @wm0018.
- **PR #98339** fix(irc): classify host-less nick!user allowlist entries as mutable. Thanks @yetval.
- **PR #97662** fix(matrix): bound raw transport response reads to prevent OOM. Thanks @Alix-007.
- **PR #98137** fix: hoist timer declaration to avoid TDZ ReferenceError in abortable delay. Thanks @zhangLei99586.
- **PR #98134** fix: clear timeout timer in Tailscale binary probe Promise.race. Thanks @zhangLei99586.
- **PR #97989** fix(sms): stop internal tool-trace banners from reaching SMS replies. Thanks @ZengWen-DT.
- **PR #97972** fix(browser): CDP auth fails with percent-encoded credentials. Thanks @VectorPeak.
- **PR #98063** fix(reply): suppress tool-error progress delivery when messages.suppressToolErrors is set. Thanks @moeedahmed and @amittell.
- **PR #94964** fix(reload): cancel deferred channel reload on in-process restart. Related #79487. Thanks @lzyyzznl and @tseller.
- **PR #98598** fix: restore main lint after timer repairs. Related #98462, #98464. Thanks @zhangLei99586.
- **PR #98587** fix(slack): guard relay WebSocket frame JSON.parse against malformed input. Thanks @lsr911 and @vincentkoc.
- **PR #90030** fix(memory-core): skip qmd zero-hit search sync. Related #90023. Thanks @sahibzada-allahyar and @ruben2000de.
- **PR #98493** fix(transcripts): close readline interface and destroy read stream on error exit. Related #98467. Thanks @wangmiao0668000666 and @zhangLei99586.
- **PR #98497** fix(cli): show exit code when plugin npm install returns empty output. Thanks @Sanjays2402 and @vincentkoc.
- **PR #97112** feat(apple): localize core native app surfaces. Thanks @vincentkoc.
- **PR #98610** fix: restore tooling CI after transcript test addition.
- **PR #77539** fix(subagent): preserve steered task text on restart redispatch. Thanks @amittell.
- **PR #97113** feat(i18n): refresh all native locale artifacts. Thanks @vincentkoc.
- **PR #98620** feat(doctor): warn about in-flight cron jobs. Thanks @masatohoshino.
- **PR #98605** test(shared): add unit tests for human-readable list formatting. Thanks @dwc1997.
- **PR #97348** feat(autoreview): support cursor-agent engine. Thanks @hxy91819.
- **PR #95943** fix(cron): preserve provider/model on isolated-run timeout row. Related #95873. Thanks @ZengWen-DT and @cursoragent and @luke-renjoy.
- **PR #94149** fix(status): bound systemd service probes so status cannot hang on a wedged systemctl (#84698). Thanks @ZengWen-DT and @cursoragent and @zus-assistant.
- **PR #88159** fix(cli): retry logs.tail after journal fallback in logs follow. Thanks @anyech and @vincentkoc.
- **PR #98508** fix(update-check): bound npm registry JSON response read to prevent OOM. Thanks @lzyyzznl.
- **PR #98496** fix(tlon): bound error response body reads to prevent OOM. Thanks @Pandah97.
- **PR #98554** fix(openai): bound embedding batch file downloads. Thanks @sunlit-deng and @vincentkoc.
- **PR #98652** fix: stop invalid message timeouts from stalling.
- **PR #77973** fix(gateway): cap agentRunCache to prevent unbounded growth under run fan-out. Related #77976. Thanks @fede-kamel and @vincentkoc.
- **PR #98525** fix(agents): time out local streams without first event. Thanks @osolmaz.
- **PR #94022** fix(cron): persist startup catch-up deferral ids in service state to prevent read-RPC clobber. Related #93935. Thanks @RichChen01 and @vincentkoc and @yetval.
- **PR #93810** fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass. Thanks @yetval and @vincentkoc.
- **PR #98623** fix: media tools skip env-key provider plugins when auto-selecting models. Thanks @medns.
- **PR #98665** fix(claude-cli): return updatedInput in can_use_tool allow response for Claude Code 2.1. Related #95171. Thanks @yetval and @carterdawson.
- **PR #94250** fix(feishu): send blocks as independent messages when blockStreaming is enabled. Related #55027. Thanks @xialonglee and @vincentkoc and @ZichaoLong.
- **PR #93379** fix(whatsapp): thread authDir through command authorization and owner bypass for LID JID resolution. Related #77755. Thanks @xialonglee and @jiveshkalra.
- **PR #98646** fix: keep workspace rail file sections scrollable. Related #98566. Thanks @wuqxuan and @645648406-max.
- **PR #98602** fix: iOS Talk fallback settings opens Voice & Talk. Related #98593. Thanks @PollyBot13.
- **PR #98611** fix(ui): add overflow-y:auto to workspace rail sections to prevent file list overflow (fixes #98566). Thanks @zw-xysk and @645648406-max.
- **PR #98619** fix(qa-lab): credential lease requests fail on oversized Convex broker responses. Thanks @ZengWen-DT.
- **PR #94326** fix(memory-wiki): disambiguate the reserved index page stem for synthesis and ingest. Thanks @yetval and @vincentkoc.
- **PR #98659** fix(codex): classify get_goal read statuses as successful dynamic tool calls. Thanks @yetval.
- **PR #96856** fix(codex): successful sessions_spawn and goal tool results recorded as failures. Thanks @nxmxbbd.
- **PR #98660** fix(inworld): guard voices JSON.parse against malformed API response bodies. Thanks @solodmd.
- **PR #95430** fix(embedded-agent-runner): pump async streamFn through pumpStreamWithRecovery for mid-stream error recovery. Related #95429. Thanks @lzyyzznl and @vincentkoc and @alexelgier.
- **PR #98644** fix: tool summaries preserve emoji truncation boundaries. Thanks @ZengWen-DT.
- **PR #80928** fix(telegram): suppress fallback reply when plugin command returns suppressReply: true. Related #80756. Thanks @alexuser and @UnClouded77.
- **PR #98701** fix: prevent agents-tools message test timeouts.
- **PR #92657** feat(usage): ship built-in /usage full footer. Thanks @Marvinthebored.
- **PR #92877** fix(usage): make built-in footer easier to wrap on Telegram. Thanks @Marvinthebored.
- **PR #98126** Restore Telegram /steer for active Codex runs. Related #81594. Thanks @100yenadmin and @Kyzcreig.
- **PR #92037** feat(cron): on-exit schedule — wake on a watched command's exit. Thanks @anagnorisis2peripeteia.
- **PR #98452** feat(ios): modernize the app with iOS 26 Liquid Glass.
- **PR #98006** Add Telegram /login Codex pairing flow. Thanks @100yenadmin.
- **PR #98735** fix(telegram): preserve rich forwarded message text. Thanks @obviyus.
- **PR #97962** refactor(qa): use transport-native actions in flow scenarios. Thanks @RomneyDa.
- **PR #98726** fix(nvidia): use Nemotron Super 1M context. Thanks @eleqtrizit.
- **PR #98691** fix(imessage): shed emoji anywhere in poll-vote echo match. Thanks @omarshahine.
- **PR #97174** Fix Telegram plugin callback routing. Thanks @goldmar.
- **PR #89597** fix: migrate QQBot credential backups to SQLite KV.
- **PR #98536** feat: prepare scoped conversation capability profiles.
- **PR #92274** fix(agents): classify embedded prompt lock error as permanent announce failure. Related #91527. Thanks @fsdwen and @zackchiutw.
- **PR #98102** fix(telegram): durably retry inbound media dropped during restart (#98076). Thanks @luoyanglang and @DaveArcher18.
- **PR #98755** fix(cron): detach session-targeted runs. Related #98121. Thanks @obviyus and @EthanSK.
- **PR #96065** fix(install): manage config-secretref env refs via OPENCLAW_SERVICE_MANAGED_ENV_KEYS. Thanks @Darren2030 and @obviyus.
- **PR #98666** fix: diagnose Windows LAN Gateway firewall blocks. Thanks @joshavant.
- **PR #98501** fix(codex): rename destructive approval mode to ask. Related #98499. Thanks @kevinslin.
- **PR #98775** fix(telegram): survive transient getUpdates errors and stop per-send cache rewrites. Related #98772, #98773. Thanks @obviyus.
- **PR #98776** fix(telegram): back off, dead-letter, and tombstone spooled updates so poison messages cannot block or duplicate. Related #98774. Thanks @obviyus.
- **PR #96454** feat(cli): openclaw attach — launch an external harness bound to a gateway session. Thanks @anagnorisis2peripeteia and @obviyus.
- **PR #98786** fix(telegram): final replies no longer drop on rejected rich entities, captions, quotes, or long flood waits. Related #98778. Thanks @obviyus.
- **PR #97496** Doctor: expose channel plugin blocker findings. Thanks @giodl73-repo.
- **PR #98792** fix(ci): restore docs and test type checks.
- **PR #98736** improve(ios): simplify Talk controls and composer alignment.
- **PR #93686** fix(weixin): startAccount preserves session routing. Related #93556. Thanks @zhangguiping-xydt and @htkillermax-gif.
## 2026.6.11
+29
View File
@@ -0,0 +1,29 @@
# feat(cron): `on-exit` schedule — fire a job when a watched command/process exits
## Problem
Event-driven wakes that start a fresh agent turn already work (the `wake`/`system event` RPC). But an agent cannot reliably arm "wake me when this command/process exits" itself: CLI backends run each turn as a supervisor-spawned **detached process group** that is `signalProcessTree(SIGTERM→SIGKILL)`'d at turn end (`src/process/supervisor/adapters/child.ts`, intentional, #71662). Any process the agent backgrounds via `exec` is in that tree and dies with the turn. The only escape (`setsid` + raw `node dist/entry.js system event …`) is hand-rolled, fragile, and observed to take down the host. Applies to **all** spawn-and-kill CLI backends (claude-cli verified), not the TLS proxy.
## Design
A new cron **schedule kind** `on-exit`, executed by a **gateway-supervisor-owned watcher** — independent of #83738 (rides the existing main-session cron run pipeline, not the manual wake path).
- `CronSchedule` gains `{ kind: "on-exit"; command: string; cwd?: string }` (PID-watch variant deferred).
- `computeNextRunAtMs()` returns `undefined` for `on-exit` → the time-based timer never fires it.
- `createCronExitWatchers()` (extracted into `src/gateway/cron-exit-watchers.ts`, wired from `buildGatewayCronService` via `reconcileExitWatchers()` / `stopExitWatchers()`) owns the watcher lifecycle, backed by `getProcessSupervisor()`. It exposes `reconcile(jobs) / cancel(jobId) / cancelAll() / activeJobIds()`.
- On reconcile, each enabled `on-exit` job reserves a watcher slot synchronously (with an `armToken`), then spawns the command via `supervisor.spawn({ mode:"child", scopeKey:"cron-exit:<jobId>", replaceExistingScope:true, argv:[shell.command, ...shell.argsFor(command)], cwd?, captureOutput:true })`. The shell is platform-aware via `resolveExitWatchShell()`: `cmd.exe /d /s /c` on Windows, `bash -lc` on POSIX.
- The watcher lives under the **gateway** supervisor tree, so per-turn CLI teardown never touches it. An async spawn/wait that loses ownership (job cancelled or re-armed for a changed command/cwd) is a no-op via the `armToken`/slot-identity check.
- `await run.wait()` → on exit, the one-shot completion is **persisted (the job disabled in the store) BEFORE the job fires**, fail-closed: if the store write or `run.wait()` rejects, the job does NOT fire — so a gateway restart cannot re-arm and double-fire the same exit. Only then does it fire via the existing cron run pipeline; the woken turn sees the exit code + last output lines.
- Job `remove`/`disable`, or a changed command/cwd → `reconcile()` cancels or re-arms the watcher (`cancel(jobId)`; `cancelAll()` on shutdown).
- Delivery to the originating conversation is the **existing** `executeMainSessionCronJob` path (`resolveMainSessionCronDeliveryContext`) — already correct on main; no dependency on #83738.
## Reuse / no new delivery code
Everything after "process exited" is the current cron run→system-event→delivery pipeline. The only new surface: the schedule kind, its validation, the watcher lifecycle, and the tool/schema plumbing to create such a job.
## Out of scope
- PID-watch (`{ kind:"on-exit"; pid }`) — follow-up.
- Re-arm/repeat on each exit — v1 is one-shot (job disables after firing, like a one-shot `at`).
- This PR **stacks on #83738** and reuses its origin-aware wake as the firing
mechanism; it adds only the process-exit _trigger_ (the supervisor watcher).
+7 -1
View File
@@ -9,6 +9,9 @@
# Build stages use full bookworm; the runtime image is always bookworm-slim.
ARG OPENCLAW_EXTENSIONS=""
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS="--max-old-space-size=8192"
ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=""
ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="docker.io/library/node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="docker.io/library/node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
@@ -49,6 +52,9 @@ FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS
ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB
ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS
# Copy pinned Bun binary from the official image instead of fetching via curl.
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
@@ -119,7 +125,7 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
# Android Release Agent Policy
Root rules still apply. This file adds the Android release guardrails.
## Google Play Releases
- Agent-driven Google Play uploads must use only `pnpm android:release:upload`.
- If `pnpm android:release:upload` exits non-zero, stop immediately and report the failing step.
- After a failed `pnpm android:release:upload`, do not continue with `pnpm android:release:archive`, `pnpm android:release:metadata`, `fastlane android play_store`, `fastlane android metadata`, direct Gradle release artifacts plus Google Play upload commands, Google Play API mutation commands, or mobile release ref recording.
- Do not promote an Android release to production. Production promotion stays manual in Google Play Console unless the user explicitly asks to promote a specific already-prepared release after the failed state has been reported.
- `pnpm android:release:archive` is for local archive validation only. It is not a fallback release path after screenshot, metadata, signing, validation, or upload-lane failure.
## Licenses Screen
- Maintain the Settings-tab Licenses screen when Android app dependencies change.
- Bundled license files live in `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/`.
- License files must be UTF-8 `.txt` files. Do not add Markdown, HTML, RTF, JSON, XML, or generated notice bundles for this screen.
- The Licenses screen discovers bundled `.txt` files at runtime through `AndroidLicenseNotices`; do not hardcode individual license rows in Compose.
- License rows are ordered alphabetically in code by derived display title, case-insensitive, with filename as the tiebreaker. Do not use numeric filename prefixes for ordering.
- The display title is the license filename without the `.txt` extension.
- Filenames should be plain dependency names, for example `Manrope.txt`; the filename is the row title and must not be shown as a row subtitle.
- Do not add OpenClaw, OpenClaw Foundation, or other first-party/self-owned license entries. The screen is for third-party/open-source dependency acknowledgements.
- When adding, removing, or upgrading Android dependencies, audit whether `apps/android/THIRD_PARTY_LICENSES/openclaw/licenses/` needs updates. Exclude dependencies owned by OpenClaw Foundation from the published license list.
- Keep license detail bodies rendered as verbatim monospace text.
- Keep the Settings `Licenses` section at the bottom of Settings, after `Account`, with a single `Licenses` row and no row subtitle unless product direction changes.
- When changing license loading or presentation, update `apps/android/app/src/test/java/ai/openclaw/app/AndroidLicenseNoticesTest.kt`, then run focused Android validation.
+7 -1
View File
@@ -2,7 +2,13 @@
## Unreleased
Maintenance update for the current OpenClaw Android release.
## 2026.6.11 - 2026-07-01
Improves Android gateway setup with localized onboarding, QR pairing fixes, and support for local mDNS gateway hosts.
Adds clearer recovery guidance for TLS fingerprint timeouts, mobile protocol mismatches, and gateway auth states.
Refreshes native Android localization coverage, including Swedish app naming and localized gateway trust flows.
## 2026.6.2 - 2026-06-02
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+2 -2
View File
@@ -2,5 +2,5 @@
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.10
OPENCLAW_ANDROID_VERSION_CODE=2026061001
OPENCLAW_ANDROID_VERSION_NAME=2026.6.11
OPENCLAW_ANDROID_VERSION_CODE=2026061101
+12 -5
View File
@@ -1,8 +1,8 @@
## OpenClaw Android App
Status: **extremely alpha**. The app is actively being rebuilt from the ground up.
OpenClaw Android is the officially released Google Play app. It connects to an OpenClaw Gateway as a companion node for chat, voice, approvals, screen, and device-aware automation.
### Rebuild Checklist
### Current App Surface
- [x] New 4-step onboarding flow
- [x] Connect tab with `Setup Code` + `Manual` modes
@@ -18,7 +18,6 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening
## Open in Android Studio
@@ -87,6 +86,15 @@ the screenshots, then shuts down the emulator it started.
`pnpm android:bundle:release` is an alias for the same Fastlane archive lane.
`pnpm android:release:archive` is for local archive validation only. It is not a
fallback upload path after `pnpm android:release:upload` fails.
Agent-driven Google Play uploads must use `pnpm android:release:upload` as the
only release path. If that command fails, stop and fix the failing screenshot,
metadata, signing, validation, archive, or upload step before trying again. Do
not upload archived artifacts through direct Fastlane lanes, Gradle artifacts,
Google Play API commands, or Play Console mutation commands.
See `apps/android/VERSIONING.md` and `apps/android/fastlane/SETUP.md` for the release workflow.
Flavor-specific direct Gradle tasks:
@@ -116,7 +124,7 @@ Direct Gradle tasks:
cd apps/android
./gradlew :app:ktlintCheck :benchmark:ktlintCheck
./gradlew :app:ktlintFormat :benchmark:ktlintFormat
./gradlew :app:lintDebug
./gradlew :app:lintPlayDebug :app:lintThirdPartyDebug
```
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
@@ -334,5 +342,4 @@ Common failure quick-fixes:
## Contributions
This Android app is currently being rebuilt.
Maintainer: @obviyus. For issues/questions/contributions, please open an issue or reach out on Discord.
@@ -0,0 +1,19 @@
Bouncy Castle Provider
Artifact: org.bouncycastle:bcprov-jdk18on
License: Bouncy Castle Licence
Bouncy Castle License
Copyright (c) 2000 - 2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,32 @@
CommonMark Java
Artifacts:
- org.commonmark:commonmark
- org.commonmark:commonmark-ext-autolink
- org.commonmark:commonmark-ext-gfm-strikethrough
- org.commonmark:commonmark-ext-gfm-tables
- org.commonmark:commonmark-ext-task-list-items
License: BSD 2-Clause
Copyright (c) 2015, Atlassian Pty Ltd
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,184 @@
Kotlin libraries
Artifacts:
- org.jetbrains.kotlin:kotlin-stdlib
- org.jetbrains.kotlinx:kotlinx-coroutines-android
- org.jetbrains.kotlinx:kotlinx-coroutines-core
- org.jetbrains.kotlinx:kotlinx-serialization-json
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,184 @@
OkHttp and Okio
Artifacts:
- com.squareup.okhttp3:okhttp
- com.squareup.okhttp3:okhttp-android
- com.squareup.okio:okio
- com.squareup.okio:okio-jvm
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,25 @@
SLF4J API
Artifact: org.slf4j:slf4j-api
License: MIT License
Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland)
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,34 @@
dnsjava
Artifact: dnsjava:dnsjava
License: BSD 3-Clause
Copyright (c) 1998-2019, Brian Wellington
Copyright (c) 2005 VeriSign. All rights reserved.
Copyright (c) 2019-2023, dnsjava authors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,23 @@
nibor autolink
Artifact: org.nibor.autolink:autolink
License: MIT License
Copyright (c) 2015 Robin Stocker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16 -3
View File
@@ -39,7 +39,7 @@ When generating `apps/android/fastlane/metadata/android/en-US/release_notes.txt`
Recommended workflow:
- while iterating on a Play internal testing train, keep pending notes under `## Unreleased`
- while iterating on a Google Play release train, keep pending notes under `## Unreleased`
- before the production release, move or copy the final notes under `## <pinned version>` and run sync again
## Release Workflow
@@ -51,8 +51,14 @@ Recommended workflow:
5. Run `pnpm android:release:preflight` to validate Play auth, signing, synced versioning, and release notes.
6. Run `ANDROID_SCREENSHOT_AVD=<avd-name> pnpm android:screenshots` to refresh raw Google Play screenshots with a script-managed emulator, or run `pnpm android:screenshots` when exactly one ADB device is already connected.
7. Run `pnpm android:release:archive` to produce the signed Play AAB and third-party APK.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to Google Play internal testing.
9. Promote to production manually in Google Play Console.
8. Run `pnpm android:release:upload` to upload metadata, screenshots, and the Play AAB to the configured Google Play track.
9. Complete production rollout manually in Google Play Console when needed.
If `pnpm android:release:upload` fails, stop at that failure. Do not continue by
uploading archived artifacts through `pnpm android:release:archive`,
`pnpm android:release:metadata`, direct Fastlane lanes, Gradle release artifacts,
Google Play API mutation commands, or Play Console mutation commands. Fix the
failing release-lane step, then rerun `pnpm android:release:upload`.
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
@@ -81,6 +87,9 @@ immutable: the same ref at the same SHA is accepted, while the same ref at a
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
not record it because no Play build is published.
Do not create this ref after a manual fallback upload. The ref is release-lane
evidence, not a repair mechanism for a failed `pnpm android:release:upload` run.
Useful direct commands:
```bash
@@ -95,3 +104,7 @@ pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-
`sync:pull` decrypts the Play upload keystore and Gradle signing properties into `apps/android/build/release-signing/`. That directory is gitignored, and Fastlane exports the materialized values as Gradle project properties for the current release command.
If `MATCH_PASSWORD` is not set, the existing manual Gradle-property signing path still works: provide `OPENCLAW_ANDROID_STORE_FILE`, `OPENCLAW_ANDROID_STORE_PASSWORD`, `OPENCLAW_ANDROID_KEY_ALIAS`, and `OPENCLAW_ANDROID_KEY_PASSWORD` through your local Gradle user properties before running release tasks.
Agent-driven releases must not use those lower-level signing and upload surfaces
to bypass a failed `pnpm android:release:upload` attempt. Report the failing
step and wait for maintainer direction instead.
+29
View File
@@ -3,6 +3,7 @@ import java.util.Properties
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
@@ -76,6 +77,7 @@ android {
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
assets.directories.add(thirdPartyLicensesDir.path)
}
}
@@ -247,6 +249,33 @@ tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
val validateThirdPartyLicenseAssets =
tasks.register("validateThirdPartyLicenseAssets") {
inputs.dir(thirdPartyLicensesDir)
doLast {
if (!thirdPartyLicensesDir.isDirectory) {
error("Missing Android third-party license directory: ${thirdPartyLicensesDir.relativeTo(rootProject.projectDir)}")
}
val invalidFiles =
thirdPartyLicensesDir
.walkTopDown()
.filter { file -> file.isFile && file.extension.lowercase() != "txt" }
.map { file -> file.relativeTo(thirdPartyLicensesDir).path }
.toList()
if (invalidFiles.isNotEmpty()) {
error(
"Android third-party license assets must be .txt files:\n" +
invalidFiles.joinToString(separator = "\n") { path -> "- $path" },
)
}
}
}
tasks.matching { task -> task.name == "preBuild" }.configureEach {
dependsOn(validateThirdPartyLicenseAssets)
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
@@ -0,0 +1,40 @@
package ai.openclaw.app
import android.content.res.AssetManager
internal const val ANDROID_LICENSE_ASSET_DIRECTORY = "openclaw/licenses"
internal data class AndroidLicenseNotice(
val title: String,
val fileName: String,
val text: String,
)
internal fun loadAndroidLicenseNotices(assetManager: AssetManager): List<AndroidLicenseNotice> {
val files =
assetManager
.list(ANDROID_LICENSE_ASSET_DIRECTORY)
.orEmpty()
.filter(::isAndroidLicenseFileName)
return files
.map { fileName ->
val rawText =
assetManager
.open("$ANDROID_LICENSE_ASSET_DIRECTORY/$fileName")
.bufferedReader(Charsets.UTF_8)
.use { reader -> reader.readText() }
AndroidLicenseNotice(title = androidLicenseTitleFromFileName(fileName), fileName = fileName, text = rawText)
}.sortedWith(
compareBy<AndroidLicenseNotice, String>(String.CASE_INSENSITIVE_ORDER) { notice -> notice.title }
.thenBy(String.CASE_INSENSITIVE_ORDER) { notice -> notice.fileName },
)
}
internal fun isAndroidLicenseFileName(fileName: String): Boolean = fileName.endsWith(".txt", ignoreCase = true)
internal fun androidLicenseTitleFromFileName(fileName: String): String =
fileName
.substringBeforeLast('.')
.trim()
.ifBlank { "License" }
@@ -97,7 +97,10 @@ internal fun parseGatewayExecApprovalDetail(
)
}
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
private fun gatewayExecApprovalListCommandText(
obj: JsonObject,
request: JsonObject?,
): String =
obj["commandText"]
.asStringOrNull()
?.trim()
@@ -0,0 +1,216 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
data class GatewayTalkSetupReadiness(
val realtimeTalk: GatewayTalkSetupState,
val dictation: GatewayTalkSetupState,
) {
companion object {
fun unverified(
issue: GatewayTalkSetupIssue = GatewayTalkSetupIssue.CatalogNotLoaded,
): GatewayTalkSetupReadiness =
GatewayTalkSetupReadiness(
realtimeTalk = GatewayTalkSetupState.Unverified(issue),
dictation = GatewayTalkSetupState.Unverified(issue),
)
}
}
sealed interface GatewayTalkSetupState {
data class Ready(
val provider: GatewayTalkProvider,
) : GatewayTalkSetupState
data class NeedsSetup(
val issue: GatewayTalkSetupIssue,
val provider: GatewayTalkProvider? = null,
) : GatewayTalkSetupState
/** Catalog failures must not disable a startup path that the Gateway still validates. */
data class Unverified(
val issue: GatewayTalkSetupIssue,
) : GatewayTalkSetupState
}
enum class GatewayTalkSetupTarget(
val title: String,
) {
REALTIME_TALK("Realtime Talk"),
DICTATION("Dictation"),
}
sealed interface GatewayTalkSetupIssue {
data object CatalogNotLoaded : GatewayTalkSetupIssue
data object CatalogLoadFailed : GatewayTalkSetupIssue
data class GroupMissing(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class NoProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnknownProvider(
val target: GatewayTalkSetupTarget,
val providerId: String,
) : GatewayTalkSetupIssue
data class MissingReadiness(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class MissingActiveProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnsupportedProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureSelectedProvider(
val providerLabel: String,
) : GatewayTalkSetupIssue
}
data class GatewayTalkProvider(
val id: String,
val label: String,
)
val GatewayTalkSetupState.isReady: Boolean
get() = this is GatewayTalkSetupState.Ready
val GatewayTalkSetupState.requiresSetup: Boolean
get() = this is GatewayTalkSetupState.NeedsSetup
fun gatewayTalkSetupStatusText(state: GatewayTalkSetupState): String =
when (state) {
is GatewayTalkSetupState.Ready -> "Ready"
is GatewayTalkSetupState.NeedsSetup -> "Needs setup"
is GatewayTalkSetupState.Unverified -> "Unverified"
}
fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String =
when (state) {
is GatewayTalkSetupState.Ready -> "${state.provider.label} via Gateway relay"
is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescription(state.issue)
is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescription(state.issue)
}
private fun gatewayTalkSetupIssueDescription(issue: GatewayTalkSetupIssue): String =
when (issue) {
GatewayTalkSetupIssue.CatalogNotLoaded -> "Gateway talk catalog not loaded"
GatewayTalkSetupIssue.CatalogLoadFailed -> "Could not load Gateway talk catalog"
is GatewayTalkSetupIssue.GroupMissing -> "Gateway did not return ${issue.target.title} setup"
is GatewayTalkSetupIssue.NoProvider -> "No ${issue.target.title} provider is configured on the Gateway"
is GatewayTalkSetupIssue.UnknownProvider -> "Gateway selected unknown provider ${issue.providerId}"
is GatewayTalkSetupIssue.MissingReadiness -> "Gateway did not return ${issue.target.title} readiness"
is GatewayTalkSetupIssue.ConfigureProvider -> "Configure a ${issue.target.title} provider on the Gateway"
is GatewayTalkSetupIssue.MissingActiveProvider ->
"Gateway did not identify the active ${issue.target.title} provider"
is GatewayTalkSetupIssue.UnsupportedProvider ->
"Choose a supported ${issue.target.title} provider on the Gateway"
is GatewayTalkSetupIssue.ConfigureSelectedProvider -> "Configure ${issue.providerLabel} on the Gateway"
}
internal fun parseGatewayTalkSetupReadiness(catalog: JsonObject?): GatewayTalkSetupReadiness {
if (catalog == null) return GatewayTalkSetupReadiness.unverified()
return GatewayTalkSetupReadiness(
realtimeTalk =
parseTalkCatalogGroup(catalog = catalog, key = "realtime", target = GatewayTalkSetupTarget.REALTIME_TALK),
dictation =
parseTalkCatalogGroup(catalog = catalog, key = "transcription", target = GatewayTalkSetupTarget.DICTATION),
)
}
private fun parseTalkCatalogGroup(
catalog: JsonObject,
key: String,
target: GatewayTalkSetupTarget,
): GatewayTalkSetupState {
val group =
catalog[key].asObjectOrNull()
?: return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.GroupMissing(target))
val providers =
(group["providers"] as? JsonArray)
?.mapNotNull(::parseTalkCatalogProvider)
.orEmpty()
val ready = (group["ready"] as? JsonPrimitive)?.booleanOrNull
val activeProviderId = group["activeProvider"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)
if (providers.isEmpty()) {
return when {
ready == false -> GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.NoProvider(target))
activeProviderId != null ->
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
else -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
if (activeProviderId == null) {
if (ready == false) {
return GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.ConfigureProvider(target))
}
// Older Gateways can omit the selected provider and report alias-backed rows as unconfigured
// even though session startup resolves them. Only an explicit readiness result is authoritative.
return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingActiveProvider(target))
}
val selected =
// Match Gateway registry precedence: canonical ids win before alias fallback.
providers.firstOrNull { it.matchesId(activeProviderId) }
?: providers.firstOrNull { it.matchesAlias(activeProviderId) }
?: return if (ready == false) {
GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.UnsupportedProvider(target))
} else {
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
}
val provider = GatewayTalkProvider(id = selected.id, label = selected.label)
return when (ready) {
true -> GatewayTalkSetupState.Ready(provider)
false ->
GatewayTalkSetupState.NeedsSetup(
issue = GatewayTalkSetupIssue.ConfigureSelectedProvider(selected.label),
provider = provider,
)
null -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
private data class TalkCatalogProvider(
val id: String,
val label: String,
val configured: Boolean,
val aliases: List<String>,
) {
fun matchesId(candidate: String): Boolean = id.equals(candidate, ignoreCase = true)
fun matchesAlias(candidate: String): Boolean = aliases.any { it.equals(candidate, ignoreCase = true) }
}
private fun parseTalkCatalogProvider(item: JsonElement): TalkCatalogProvider? {
val value = item.asObjectOrNull() ?: return null
val id = value["id"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null
val label = value["label"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: id
val aliases =
(value["aliases"] as? JsonArray)
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) }
.orEmpty()
return TalkCatalogProvider(
id = id,
label = label,
configured = (value["configured"] as? JsonPrimitive)?.booleanOrNull == true,
aliases = aliases,
)
}
@@ -11,6 +11,8 @@ import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.ui.GatewayConnectPlan
import ai.openclaw.app.ui.GatewaySavedAuthAction
import ai.openclaw.app.voice.VoiceConversationEntry
import android.app.Application
import androidx.lifecycle.AndroidViewModel
@@ -111,10 +113,12 @@ class MainViewModel(
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> =
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> =
runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> =
runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
@@ -123,6 +127,8 @@ class MainViewModel(
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
val talkSetupReadiness: StateFlow<GatewayTalkSetupReadiness> =
runtimeState(initial = GatewayTalkSetupReadiness.unverified()) { it.talkSetupReadiness }
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
@@ -277,10 +283,6 @@ class MainViewModel(
prefs.setManualTls(value)
}
fun setGatewayToken(value: String) {
prefs.setGatewayToken(value)
}
fun setGatewayBootstrapToken(value: String) {
prefs.setGatewayBootstrapToken(value)
}
@@ -302,37 +304,44 @@ class MainViewModel(
deviceAuthStore.clearToken(deviceId, "operator")
}
fun saveGatewayConfigAndConnect(
host: String,
port: Int,
tls: Boolean,
token: String,
bootstrapToken: String,
password: String,
resetSetupAuth: Boolean,
) {
internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) {
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
viewModelScope.launch(Dispatchers.Default) {
if (resetSetupAuth) {
val config = plan.config
val replacesSavedAuth = plan.savedAuthAction != GatewaySavedAuthAction.PRESERVE
val hasExplicitAuth =
config.token.isNotEmpty() || config.bootstrapToken.isNotEmpty() || config.password.isNotEmpty()
if (replacesSavedAuth) {
resetGatewaySetupAuth()
}
prefs.setManualEnabled(true)
prefs.setManualHost(host)
prefs.setManualPort(port)
prefs.setManualTls(tls)
prefs.setGatewayBootstrapToken(bootstrapToken)
prefs.setGatewayToken(token)
prefs.setGatewayPassword(password)
ensureRuntime()
.connect(
GatewayEndpoint.manual(host = host, port = port),
prefs.setManualHost(config.host)
prefs.setManualPort(config.port)
prefs.setManualTls(config.tls)
// A blank same-endpoint save means "keep access". Secrets remain runtime-owned,
// including password-only setups that Compose deliberately cannot read back.
if (replacesSavedAuth || hasExplicitAuth) {
prefs.setGatewayBootstrapToken(config.bootstrapToken)
prefs.setGatewayToken(config.token)
prefs.setGatewayPassword(config.password)
}
val runtime = ensureRuntime()
val endpoint = GatewayEndpoint.manual(host = config.host, port = config.port)
if (replacesSavedAuth || hasExplicitAuth) {
runtime.connect(
endpoint,
NodeRuntime.GatewayConnectAuth(
token = token.ifEmpty { null },
bootstrapToken = bootstrapToken.ifEmpty { null },
password = password.ifEmpty { null },
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
),
)
} else {
runtime.connect(endpoint)
}
}
}
@@ -520,6 +529,10 @@ class MainViewModel(
ensureRuntime().refreshModelCatalog()
}
fun refreshTalkSetupReadiness() {
ensureRuntime().refreshTalkSetupReadiness()
}
fun refreshAgents() {
ensureRuntime().refreshAgents()
}
@@ -36,21 +36,20 @@ class NodeForegroundService : Service() {
stopSelf()
return
}
// Split connection and capture flows before combining so notification text
// Keep the connection tuple atomic, then split connection and capture work so notification text
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
combine(
combine(
runtime.statusText,
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.isConnected,
runtime.voiceCaptureMode,
) { status, server, connected, mode ->
) { connection, server, mode ->
VoiceNotificationBase(
status = status,
status = connection.statusText,
server = server,
connected = connected,
connected = connection.isConnected,
mode = mode,
)
},
@@ -13,6 +13,8 @@ import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.NodeEventSendOutcome
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
@@ -59,6 +61,7 @@ import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -79,6 +82,141 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
internal data class PendingNotificationNodeEvent(
val event: String,
val payloadJson: String?,
)
private data class QueuedNotificationNodeEvent(
val generation: Long,
val event: PendingNotificationNodeEvent,
)
internal class NotificationNodeEventOutbox(
private val capacity: Int = MAX_PENDING_NOTIFICATION_EVENTS,
private val isAuthorized: (PendingNotificationNodeEvent) -> Boolean = { true },
private val isConnected: () -> Boolean = { true },
private val deliveryIntervalMs: () -> Long = { 0L },
private val nowEpochMs: () -> Long = System::currentTimeMillis,
private val sleep: suspend (Long) -> Unit = { delay(it) },
private val invalidateConnection: () -> Unit = {},
private val send: suspend (PendingNotificationNodeEvent) -> NodeEventSendOutcome,
) {
private val stateLock = Any()
private val generation = AtomicLong()
private val lastDeliveryAtMs = AtomicLong(-1L)
private val pending = ArrayDeque<QueuedNotificationNodeEvent>(capacity)
private val wakeDelivery = Channel<Unit>(Channel.CONFLATED)
private var inFlight: QueuedNotificationNodeEvent? = null
init {
require(capacity > 0) { "capacity must be positive" }
}
fun enqueue(event: PendingNotificationNodeEvent) {
synchronized(stateLock) {
if (pending.size == capacity) pending.removeFirst()
pending.addLast(QueuedNotificationNodeEvent(generation = generation.get(), event = event))
}
wakeDelivery.trySend(Unit)
}
fun clear() {
synchronized(stateLock) {
clearLocked()
}
wakeDelivery.trySend(Unit)
}
fun <T> updatePolicy(update: () -> T): T {
val result =
synchronized(stateLock) {
// Admission checks share this lock, so the new policy is visible before the next generation.
update().also { clearLocked() }
}
wakeDelivery.trySend(Unit)
return result
}
fun onConnected() {
wakeDelivery.trySend(Unit)
}
suspend fun deliver() {
while (true) {
wakeDelivery.receive()
while (true) {
val queued = synchronized(stateLock) { pending.firstOrNull() } ?: break
if (queued.generation != generation.get() || !isAuthorized(queued.event)) {
synchronized(stateLock) {
if (pending.firstOrNull() === queued) pending.removeFirst()
}
continue
}
if (!isConnected()) break
if (!awaitDeliverySlot(queued)) continue
val admitted =
synchronized(stateLock) {
if (
pending.firstOrNull() !== queued ||
queued.generation != generation.get() ||
!isAuthorized(queued.event) ||
!isConnected()
) {
false
} else {
pending.removeFirst()
inFlight = queued
true
}
}
if (!admitted) continue
val outcome = send(queued.event)
synchronized(stateLock) {
if (inFlight === queued) inFlight = null
if (queued.generation == generation.get() && isAuthorized(queued.event)) {
when (outcome) {
NodeEventSendOutcome.COMPLETED -> lastDeliveryAtMs.set(nowEpochMs())
NodeEventSendOutcome.DISCONNECTED -> {
// This outcome is rejected before send, so it is safe to retain for reconnect.
if (pending.size == capacity) pending.removeLast()
pending.addFirst(queued)
}
// Ambiguous failures may have reached the gateway: do not retry, but charge their rate slot.
NodeEventSendOutcome.FAILED -> lastDeliveryAtMs.set(nowEpochMs())
}
}
}
if (outcome == NodeEventSendOutcome.DISCONNECTED) break
}
}
}
private suspend fun awaitDeliverySlot(queued: QueuedNotificationNodeEvent): Boolean {
while (queued.generation == generation.get() && isAuthorized(queued.event)) {
val lastDelivery = lastDeliveryAtMs.get()
if (lastDelivery < 0L) return true
val waitMs = lastDelivery + deliveryIntervalMs().coerceAtLeast(0L) - nowEpochMs()
if (waitMs <= 0L) return true
// Short slices make policy/gateway invalidation responsive without charging stale quota.
sleep(minOf(waitMs, 250L))
}
return false
}
private fun clearLocked() {
// Only an admitted RPC needs transport invalidation; queued payloads have no socket side effect.
if (inFlight?.generation == generation.get()) invalidateConnection()
generation.incrementAndGet()
lastDeliveryAtMs.set(-1L)
pending.clear()
}
}
/**
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
*/
@@ -106,6 +244,48 @@ data class GatewayConnectionProblem(
)
}
data class GatewayConnectionDisplay(
val isConnected: Boolean,
val statusText: String,
val problem: GatewayConnectionProblem?,
)
private fun gatewayProblemAfterDisconnect(
problem: GatewayConnectionProblem?,
statusText: String,
): GatewayConnectionProblem? =
// Automatic bootstrap pairing retries need their approval guidance until success or a different failure.
problem?.takeIf { statusText == "Reconnecting…" && it.canAutoRetry }
internal fun gatewayConnectionDisplay(
operatorConnected: Boolean,
nodeConnected: Boolean,
operatorStatusText: String,
nodeStatusText: String,
operatorProblem: GatewayConnectionProblem?,
nodeProblem: GatewayConnectionProblem?,
): GatewayConnectionDisplay {
val operator = operatorStatusText.trim()
val node = nodeStatusText.trim()
return when {
operatorConnected && nodeConnected -> GatewayConnectionDisplay(true, "Connected", null)
operatorConnected -> GatewayConnectionDisplay(true, "Connected (node offline)", nodeProblem)
nodeConnected ->
GatewayConnectionDisplay(
isConnected = false,
statusText =
if (operator.isNotEmpty() && operator != "Offline") {
"Connected (operator: $operator)"
} else {
"Connected (operator offline)"
},
problem = operatorProblem,
)
operator.isNotBlank() && operator != "Offline" -> GatewayConnectionDisplay(false, operator, operatorProblem)
else -> GatewayConnectionDisplay(false, node, nodeProblem)
}
}
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
@@ -310,9 +490,11 @@ class NodeRuntime(
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _nodeConnected = MutableStateFlow(false)
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> = _nodeCapabilityApproval.asStateFlow()
private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, "Offline", null))
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> = _gatewayConnectionDisplay.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
@@ -369,6 +551,8 @@ class NodeRuntime(
val modelCatalogRefreshing: StateFlow<Boolean> = _modelCatalogRefreshing.asStateFlow()
private val _modelCatalogErrorText = MutableStateFlow<String?>(null)
val modelCatalogErrorText: StateFlow<String?> = _modelCatalogErrorText.asStateFlow()
private val _talkSetupReadiness = MutableStateFlow(GatewayTalkSetupReadiness.unverified())
val talkSetupReadiness: StateFlow<GatewayTalkSetupReadiness> = _talkSetupReadiness.asStateFlow()
private val _gatewayDefaultAgentId = MutableStateFlow<String?>(null)
val gatewayDefaultAgentId: StateFlow<String?> = _gatewayDefaultAgentId.asStateFlow()
private val _gatewayAgents = MutableStateFlow<List<GatewayAgentSummary>>(emptyList())
@@ -445,6 +629,9 @@ class NodeRuntime(
private var operatorConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
private var operatorConnectionProblem: GatewayConnectionProblem? = null
private var nodeConnectionProblem: GatewayConnectionProblem? = null
private val gatewayStatusLock = Any()
private val operatorSession =
GatewaySession(
@@ -452,16 +639,17 @@ class NodeRuntime(
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { hello ->
_gatewayConnectionProblem.value = null
operatorConnected = true
operatorStatusText = "Connected"
_serverName.value = hello.serverName
_remoteAddress.value = hello.remoteAddress
_gatewayVersion.value = hello.serverVersion
_gatewayUpdateAvailable.value = hello.updateAvailable
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
updateStatus()
updateStatus {
operatorConnectionProblem = null
operatorConnected = true
operatorStatusText = "Connected"
}
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
@@ -473,9 +661,7 @@ class NodeRuntime(
}
},
onDisconnected = { message ->
operatorConnected = false
invalidateNodeCapabilityApprovalState()
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
_gatewayVersion.value = null
@@ -485,6 +671,7 @@ class NodeRuntime(
_gatewayAgents.value = emptyList()
_modelCatalog.value = emptyList()
_modelAuthProviders.value = emptyList()
_talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified()
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
_cronJobs.value = emptyList()
_usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())
@@ -505,10 +692,18 @@ class NodeRuntime(
_healthLogsSummary.value = GatewayHealthLogsSummary()
chat.applyMainSessionKey(resolveMainSessionKey())
chat.onDisconnected(message)
updateStatus()
updateStatus {
operatorConnected = false
operatorStatusText = message
operatorConnectionProblem = gatewayProblemAfterDisconnect(operatorConnectionProblem, message)
}
micCapture.onGatewayConnectionChanged(false)
},
onConnectFailure = ::handleGatewayConnectFailure,
onConnectFailure = { error, pauseReconnect ->
updateStatus {
operatorConnectionProblem = gatewayConnectionProblem(error, pauseReconnect)
}
},
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
},
@@ -528,14 +723,16 @@ class NodeRuntime(
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = {
_gatewayConnectionProblem.value = null
_nodeConnected.value = true
nodeStatusText = "Connected"
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
updateStatus {
nodeConnectionProblem = null
_nodeConnected.value = true
nodeStatusText = "Connected"
}
notificationOutbox.onConnected()
showLocalCanvasOnConnect()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
@@ -547,17 +744,23 @@ class NodeRuntime(
}
},
onDisconnected = { message ->
_nodeConnected.value = false
invalidateNodeCapabilityApprovalState()
nodeStatusText = message
didAutoRequestCanvasRehydrate = false
_canvasA2uiHydrated.value = false
_canvasRehydratePending.value = false
_canvasRehydrateErrorText.value = null
updateStatus()
updateStatus {
_nodeConnected.value = false
nodeStatusText = message
nodeConnectionProblem = gatewayProblemAfterDisconnect(nodeConnectionProblem, message)
}
showLocalCanvasOnDisconnect()
},
onConnectFailure = ::handleGatewayConnectFailure,
onConnectFailure = { error, pauseReconnect ->
updateStatus {
nodeConnectionProblem = gatewayConnectionProblem(error, pauseReconnect)
}
},
onEvent = { _, _ -> },
onInvoke = { req ->
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
@@ -567,11 +770,49 @@ class NodeRuntime(
},
)
private val notificationOutbox: NotificationNodeEventOutbox by lazy {
NotificationNodeEventOutbox(
isAuthorized = ::isNotificationEventStillAuthorized,
isConnected = nodeSession::isReady,
deliveryIntervalMs = ::notificationDeliveryIntervalMs,
invalidateConnection = nodeSession::reconnect,
send = { pending ->
nodeSession.sendNodeEventWithOutcome(event = pending.event, payloadJson = pending.payloadJson)
},
)
}
private fun notificationDeliveryIntervalMs(): Long {
val maxEvents =
prefs.notificationForwardingMaxEventsPerMinute.value
.coerceAtLeast(1)
.toLong()
return (60_000L + maxEvents - 1L) / maxEvents
}
private fun isNotificationEventStillAuthorized(event: PendingNotificationNodeEvent): Boolean {
if (event.event != "notifications.changed") return false
if (!DeviceNotificationListenerService.isAccessEnabled(appContext)) return false
val payload =
runCatching { event.payloadJson?.let(json::parseToJsonElement).asObjectOrNull() }
.getOrNull()
?: return false
val packageName = payload["packageName"].asStringOrNull()?.trim().orEmpty()
if (packageName.isEmpty()) return false
val policy = prefs.getNotificationForwardingPolicy(appPackageName = appContext.packageName)
val eventSessionKey = payload["sessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
return policy.enabled &&
policy.sessionKey == eventSessionKey &&
policy.allowsPackage(packageName) &&
!policy.isWithinQuietHours(nowEpochMs = System.currentTimeMillis())
}
init {
scope.launch { notificationOutbox.deliver() }
DeviceNotificationListenerService.setNodeEventSink { event, payloadJson ->
scope.launch {
nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson)
}
notificationOutbox.enqueue(
PendingNotificationNodeEvent(event = event, payloadJson = payloadJson),
)
}
}
@@ -591,7 +832,7 @@ class NodeRuntime(
context = appContext,
scope = scope,
session = operatorSession,
isConnected = { _isConnected.value },
isConnected = { gatewayConnectionDisplay.value.isConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
).also { speaker ->
@@ -702,7 +943,7 @@ class NodeRuntime(
context = appContext,
scope = scope,
session = operatorSession,
isConnected = { _isConnected.value },
isConnected = { gatewayConnectionDisplay.value.isConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
@@ -736,45 +977,56 @@ class NodeRuntime(
updateHomeCanvasState()
}
private fun updateStatus() {
_isConnected.value = operatorConnected
val operator = operatorStatusText.trim()
val node = nodeStatusText.trim()
_statusText.value =
when {
operatorConnected && _nodeConnected.value -> "Connected"
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
!operatorConnected && _nodeConnected.value ->
if (operator.isNotEmpty() && operator != "Offline") {
"Connected (operator: $operator)"
} else {
"Connected (operator offline)"
}
operator.isNotBlank() && operator != "Offline" -> operator
else -> node
}
private fun updateStatus(update: () -> Unit = {}) {
synchronized(gatewayStatusLock) {
update()
// Select and publish text plus diagnostics atomically; operator and node callbacks run concurrently.
val display =
gatewayConnectionDisplay(
operatorConnected = operatorConnected,
nodeConnected = _nodeConnected.value,
operatorStatusText = operatorStatusText,
nodeStatusText = nodeStatusText,
operatorProblem = operatorConnectionProblem,
nodeProblem = nodeConnectionProblem,
)
_gatewayConnectionDisplay.value = display
_isConnected.value = display.isConnected
_statusText.value = display.statusText
_gatewayConnectionProblem.value = display.problem
}
updateHomeCanvasState()
}
private fun handleGatewayConnectFailure(
private fun setStandaloneGatewayStatus(statusText: String) {
synchronized(gatewayStatusLock) {
val display = GatewayConnectionDisplay(operatorConnected, statusText, null)
_gatewayConnectionDisplay.value = display
_isConnected.value = display.isConnected
_statusText.value = display.statusText
_gatewayConnectionProblem.value = display.problem
}
updateHomeCanvasState()
}
private fun gatewayConnectionProblem(
error: GatewaySession.ErrorShape,
pauseReconnect: Boolean,
) {
): GatewayConnectionProblem {
val details = error.details
_gatewayConnectionProblem.value =
GatewayConnectionProblem(
code = details?.code ?: error.code,
message = error.message,
reason = details?.reason,
requestId = details?.requestId,
recommendedNextStep = details?.recommendedNextStep,
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
retryable = details?.retryable == true,
clientMinProtocol = details?.clientMinProtocol,
clientMaxProtocol = details?.clientMaxProtocol,
expectedProtocol = details?.expectedProtocol,
minimumProbeProtocol = details?.minimumProbeProtocol,
)
return GatewayConnectionProblem(
code = details?.code ?: error.code,
message = error.message,
reason = details?.reason,
requestId = details?.requestId,
recommendedNextStep = details?.recommendedNextStep,
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
retryable = details?.retryable == true,
clientMinProtocol = details?.clientMinProtocol,
clientMaxProtocol = details?.clientMaxProtocol,
expectedProtocol = details?.expectedProtocol,
minimumProbeProtocol = details?.minimumProbeProtocol,
)
}
private fun resolveMainSessionKey(): String {
@@ -805,6 +1057,7 @@ class NodeRuntime(
refreshBrandingFromGateway()
refreshAgentsFromGateway()
refreshModelCatalogFromGateway()
refreshTalkSetupReadinessFromGateway()
refreshCronFromGateway()
refreshUsageFromGateway()
refreshSkillsFromGateway()
@@ -821,6 +1074,10 @@ class NodeRuntime(
}
}
fun refreshTalkSetupReadiness() {
scope.launch { refreshTalkSetupReadinessFromGateway() }
}
fun refreshAgents() {
scope.launch {
refreshAgentsFromGateway()
@@ -1130,7 +1387,7 @@ class NodeRuntime(
private fun autoConnectIfNeeded() {
if (didAutoConnect) return
if (_isConnected.value) return
if (gatewayConnectionDisplay.value.isConnected) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
// Only attempt the stored preferred gateway once per runtime lifetime; users
// can still reconnect explicitly from the UI after a failed auto attempt.
@@ -1139,7 +1396,7 @@ class NodeRuntime(
}
private fun reconnectPreferredGatewayOnForeground() {
if (_isConnected.value) return
if (gatewayConnectionDisplay.value.isConnected) return
if (_pendingGatewayTrust.value != null) return
if (connectedEndpoint != null) {
refreshGatewayConnection()
@@ -1195,34 +1452,67 @@ class NodeRuntime(
}
fun setNotificationForwardingEnabled(value: Boolean) {
prefs.setNotificationForwardingEnabled(value)
if (prefs.notificationForwardingEnabled.value == value) return
notificationOutbox.updatePolicy { prefs.setNotificationForwardingEnabled(value) }
}
fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
prefs.setNotificationForwardingMode(mode)
if (prefs.notificationForwardingMode.value == mode) return
notificationOutbox.updatePolicy { prefs.setNotificationForwardingMode(mode) }
}
fun setNotificationForwardingPackages(packages: List<String>) {
prefs.setNotificationForwardingPackages(packages)
val normalized = packages.map(String::trim).filter(String::isNotEmpty).toSet()
if (prefs.notificationForwardingPackages.value == normalized) return
notificationOutbox.updatePolicy { prefs.setNotificationForwardingPackages(normalized.toList()) }
}
fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean = prefs.setNotificationForwardingQuietHours(enabled = enabled, start = start, end = end)
): Boolean {
if (!enabled) {
if (!prefs.notificationForwardingQuietHoursEnabled.value) return true
return notificationOutbox.updatePolicy {
prefs.setNotificationForwardingQuietHours(enabled = false, start = start, end = end)
}
}
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
val unchanged =
prefs.notificationForwardingQuietHoursEnabled.value &&
prefs.notificationForwardingQuietStart.value == normalizedStart &&
prefs.notificationForwardingQuietEnd.value == normalizedEnd
if (unchanged) return true
return notificationOutbox.updatePolicy {
prefs.setNotificationForwardingQuietHours(
enabled = true,
start = normalizedStart,
end = normalizedEnd,
)
}
}
fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
prefs.setNotificationForwardingMaxEventsPerMinute(value)
val normalized = value.coerceAtLeast(1)
if (prefs.notificationForwardingMaxEventsPerMinute.value == normalized) return
notificationOutbox.updatePolicy {
prefs.setNotificationForwardingMaxEventsPerMinute(normalized)
}
}
fun setNotificationForwardingSessionKey(value: String?) {
prefs.setNotificationForwardingSessionKey(value)
val normalized = value?.trim()?.takeIf(String::isNotEmpty)
if (prefs.notificationForwardingSessionKey.value == normalized) return
notificationOutbox.updatePolicy { prefs.setNotificationForwardingSessionKey(normalized) }
}
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
stopManualVoiceSession()
} else {
refreshTalkSetupReadiness()
}
// Don't re-enable on active=true; mic toggle drives that
}
@@ -1242,9 +1532,15 @@ class NodeRuntime(
}
private suspend fun handleTalkPttStart(): GatewaySession.InvokeResult =
runPreparedTalkPttCommand {
val payload = talkMode.beginPushToTalk()
GatewaySession.InvokeResult.ok(payload.toJson())
runTalkPttCommand {
if (!_isForeground.value) {
val payload = talkMode.beginPushToTalk(allowNewCapture = false)
return@runTalkPttCommand GatewaySession.InvokeResult.ok(payload.toJson())
}
runPreparedTalkPttCommand {
val payload = talkMode.beginPushToTalk(allowNewCapture = true)
GatewaySession.InvokeResult.ok(payload.toJson())
}
}
private suspend fun handleTalkPttStop(): GatewaySession.InvokeResult =
@@ -1288,6 +1584,9 @@ class NodeRuntime(
}
private suspend fun prepareTalkCapture() {
if (!_isForeground.value) {
throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground")
}
if (!hasRecordAudioPermission()) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
@@ -1344,7 +1643,7 @@ class NodeRuntime(
if (!BuildConfig.DEBUG) {
throw IllegalStateException("voice e2e is debug-only")
}
if (!_isConnected.value) {
if (!gatewayConnectionDisplay.value.isConnected) {
throw IllegalStateException("gateway not connected")
}
if (!hasRecordAudioPermission()) {
@@ -1529,12 +1828,14 @@ class NodeRuntime(
if (endpoint == null) {
resolvePreferredGatewayEndpoint()?.let(::connect)
?: run {
_statusText.value = "Failed: no saved gateway endpoint"
setStandaloneGatewayStatus("Failed: no saved gateway endpoint")
}
return
}
operatorStatusText = "Connecting…"
updateStatus()
updateStatus {
operatorStatusText = "Connecting…"
operatorConnectionProblem = null
}
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
}
@@ -1556,10 +1857,12 @@ class NodeRuntime(
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
)
if (operatorAuth == null) {
operatorConnected = false
operatorStatusText = "Offline"
updateStatus {
operatorConnected = false
operatorStatusText = "Offline"
operatorConnectionProblem = null
}
operatorSession.disconnect()
updateStatus()
} else {
operatorSession.connect(
endpoint,
@@ -1590,6 +1893,9 @@ class NodeRuntime(
endpoint: GatewayEndpoint,
auth: GatewayConnectAuth,
) {
// A user-selected connect target must never inherit notification content from another gateway.
notificationOutbox.clear()
invalidateNodeCapabilityApprovalState()
val connectAttemptId = connectAttemptSeq.incrementAndGet()
_pendingGatewayTrust.value = null
val tls = connectionManager.resolveTlsParams(endpoint)
@@ -1598,14 +1904,14 @@ class NodeRuntime(
tls.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
_statusText.value = "Verify gateway TLS fingerprint…"
setStandaloneGatewayStatus("Verify gateway TLS fingerprint…")
scope.launch {
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
if (!isCurrentConnectAttempt(connectAttemptId)) return@launch
val fp =
tlsProbe.fingerprintSha256 ?: run {
if (expectedFingerprint == null) {
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
setStandaloneGatewayStatus(gatewayTlsProbeFailureMessage(tlsProbe.failure))
} else {
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
}
@@ -1642,11 +1948,13 @@ class NodeRuntime(
connectAttemptId: Long,
) {
if (!isCurrentConnectAttempt(connectAttemptId)) return
_gatewayConnectionProblem.value = null
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
updateStatus {
operatorConnectionProblem = null
nodeConnectionProblem = null
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
}
connectWithAuth(endpoint = endpoint, auth = auth)
}
@@ -1678,7 +1986,7 @@ class NodeRuntime(
fun declineGatewayTrustPrompt() {
_pendingGatewayTrust.value = null
_statusText.value = "Offline"
setStandaloneGatewayStatus("Offline")
}
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String =
@@ -1701,7 +2009,7 @@ class NodeRuntime(
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port <= 0 || port > 65535) {
_statusText.value = "Failed: invalid manual host/port"
setStandaloneGatewayStatus("Failed: invalid manual host/port")
return
}
connect(GatewayEndpoint.manual(host = host, port = port))
@@ -1724,8 +2032,10 @@ class NodeRuntime(
auth = auth,
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
) ?: return
operatorStatusText = "Connecting…"
updateStatus()
updateStatus {
operatorStatusText = "Connecting…"
operatorConnectionProblem = null
}
operatorSession.connect(
endpoint,
operatorAuth.token,
@@ -1737,11 +2047,15 @@ class NodeRuntime(
}
fun disconnect() {
notificationOutbox.clear()
connectAttemptSeq.incrementAndGet()
stopActiveVoiceSession()
connectedEndpoint = null
activeGatewayAuth = null
_gatewayConnectionProblem.value = null
updateStatus {
operatorConnectionProblem = null
nodeConnectionProblem = null
}
_pendingGatewayTrust.value = null
operatorSession.disconnect()
nodeSession.disconnect()
@@ -1945,7 +2259,7 @@ class NodeRuntime(
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
if (!gatewayConnectionDisplay.value.isConnected) return
try {
val res = operatorSession.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
@@ -2021,6 +2335,20 @@ class NodeRuntime(
}
}
private suspend fun refreshTalkSetupReadinessFromGateway() {
if (!operatorConnected) {
_talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified()
return
}
_talkSetupReadiness.value =
try {
val response = operatorSession.request("talk.catalog", "{}")
parseGatewayTalkSetupReadiness(json.parseToJsonElement(response).asObjectOrNull())
} catch (_: Throwable) {
GatewayTalkSetupReadiness.unverified(GatewayTalkSetupIssue.CatalogLoadFailed)
}
}
private suspend fun refreshCronFromGateway() {
_cronRefreshing.value = true
_cronErrorText.value = null
@@ -2107,7 +2435,16 @@ class NodeRuntime(
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodesDevicesRefreshing.value = true
_nodesDevicesErrorText.value = null
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
val pendingFallback = _nodeCapabilityApproval.value.withoutExactRequestId()
if (pendingFallback != null) {
_nodeCapabilityApproval.value = pendingFallback
} else if (
_nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingApproval &&
_nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingReapproval
) {
_nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading
}
}
if (!refreshStarted) return
if (!operatorConnected) {
@@ -2126,18 +2463,19 @@ class NodeRuntime(
val nodesRes = operatorSession.request("node.list", "{}")
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
val approvalState =
currentNodeCapabilityApprovalState(
val approval =
currentNodeCapabilityApproval(
nodes = nodes,
selfNodeId = identityStore.loadOrCreate().deviceId,
)
val publishedApproval =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = approvalState
_nodeCapabilityApproval.value = approval
}
if (!publishedApproval) {
return
}
scheduleNodeApprovalCommandRefresh(refreshGeneration, approval)
val devicesRoot =
try {
val devicesRes = operatorSession.request("device.pair.list", "{}")
@@ -2165,6 +2503,26 @@ class NodeRuntime(
}
}
private fun scheduleNodeApprovalCommandRefresh(
refreshGeneration: Long,
approval: GatewayNodeCapabilityApproval,
) {
val fallback = approval.withoutExactRequestId() ?: return
scope.launch {
delay(NODE_APPROVAL_COMMAND_FRESH_MS)
// Pairing request IDs expire on the Gateway. Age out cached commands before rechecking so
// recovery never leaves an old exact ID visible when a refresh fails or races disconnect.
val shouldRefresh =
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApproval.value = fallback
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
}
if (shouldRefresh && operatorConnected) {
refreshNodesDevicesFromGateway()
}
}
}
private suspend fun refreshExecApprovalsFromGateway() {
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = true
@@ -2358,7 +2716,8 @@ class NodeRuntime(
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
_nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
_nodesDevicesRefreshing.value = false
}
}
@@ -2917,9 +3276,10 @@ class NodeRuntime(
}
private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState {
val lower = _statusText.value.trim().lowercase()
val display = gatewayConnectionDisplay.value
val lower = display.statusText.trim().lowercase()
return when {
_isConnected.value -> HomeCanvasGatewayState.Connected
display.isConnected -> HomeCanvasGatewayState.Connected
lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting
lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error
else -> HomeCanvasGatewayState.Offline
@@ -3150,6 +3510,36 @@ enum class GatewayNodeApprovalState {
Unapproved,
}
/** Current phone approval state; only pending variants can carry an approval target. */
sealed interface GatewayNodeCapabilityApproval {
data object Loading : GatewayNodeCapabilityApproval
data object Unsupported : GatewayNodeCapabilityApproval
data object Approved : GatewayNodeCapabilityApproval
data class PendingApproval(
val requestId: String?,
) : GatewayNodeCapabilityApproval
data class PendingReapproval(
val requestId: String?,
) : GatewayNodeCapabilityApproval
data object Unapproved : GatewayNodeCapabilityApproval
}
internal fun GatewayNodeCapabilityApproval.withoutExactRequestId(): GatewayNodeCapabilityApproval? =
when (this) {
is GatewayNodeCapabilityApproval.PendingApproval ->
requestId?.let { GatewayNodeCapabilityApproval.PendingApproval(requestId = null) }
is GatewayNodeCapabilityApproval.PendingReapproval ->
requestId?.let { GatewayNodeCapabilityApproval.PendingReapproval(requestId = null) }
else -> null
}
internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds(): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null) })
/** Prevents older node.list responses from overwriting newer approval state. */
internal class GatewayNodeApprovalRefreshGuard {
private val lock = Any()
@@ -3182,14 +3572,26 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta
else -> GatewayNodeApprovalState.Loading
}
internal fun currentNodeCapabilityApprovalState(
internal fun currentNodeCapabilityApproval(
nodes: List<GatewayNodeSummary>,
selfNodeId: String,
): GatewayNodeApprovalState =
nodes
.firstOrNull { it.id == selfNodeId }
?.approvalState
?: GatewayNodeApprovalState.Loading
): GatewayNodeCapabilityApproval {
val node = nodes.firstOrNull { it.id == selfNodeId } ?: return GatewayNodeCapabilityApproval.Loading
return when (node.approvalState) {
GatewayNodeApprovalState.Loading -> GatewayNodeCapabilityApproval.Loading
GatewayNodeApprovalState.Unsupported -> GatewayNodeCapabilityApproval.Unsupported
GatewayNodeApprovalState.Approved -> GatewayNodeCapabilityApproval.Approved
GatewayNodeApprovalState.PendingApproval ->
GatewayNodeCapabilityApproval.PendingApproval(
normalizeGatewayApprovalRequestId(node.pendingRequestId),
)
GatewayNodeApprovalState.PendingReapproval ->
GatewayNodeCapabilityApproval.PendingReapproval(
normalizeGatewayApprovalRequestId(node.pendingRequestId),
)
GatewayNodeApprovalState.Unapproved -> GatewayNodeCapabilityApproval.Unapproved
}
}
internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
val obj = item.asObjectOrNull() ?: return null
@@ -3210,7 +3612,7 @@ internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
} else {
GatewayNodeApprovalState.Unsupported
},
pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
pendingRequestId = normalizeGatewayApprovalRequestId(obj["pendingRequestId"].asStringOrNull()),
capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
)
@@ -278,6 +278,7 @@ class PermissionRequester internal constructor(
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED -> "Photos"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
else -> permission
}
@@ -0,0 +1,23 @@
package ai.openclaw.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal fun photoReadPermissionsForRequest(): List<String> =
when {
Build.VERSION.SDK_INT >= 34 ->
listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
)
Build.VERSION.SDK_INT >= 33 -> listOf(Manifest.permission.READ_MEDIA_IMAGES)
else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
internal fun hasPhotoReadPermission(context: Context): Boolean =
photoReadPermissionsForRequest().any { permission ->
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
@@ -31,7 +31,7 @@ import okhttp3.WebSocketListener
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Identity advertised during gateway connect; these fields become the device row users approve.
@@ -85,6 +85,14 @@ data class GatewayConnectErrorDetails(
val minimumProbeProtocol: Int? = null,
)
private val gatewayApprovalRequestIdPattern = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
/** Keeps copied approval commands single-argument and safe for a gateway host shell. */
internal fun normalizeGatewayApprovalRequestId(requestId: String?): String? {
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
return trimmed.takeIf { gatewayApprovalRequestIdPattern.matches(it) }
}
/**
* Server hello fields cached by the Android runtime after a successful connect.
*/
@@ -117,6 +125,16 @@ private class GatewayConnectFailure(
val gatewayError: GatewaySession.ErrorShape,
) : IllegalStateException(gatewayError.message)
private class GatewayRequestNotEnqueued(
message: String,
) : IllegalStateException(message)
internal enum class NodeEventSendOutcome {
COMPLETED,
DISCONNECTED,
FAILED,
}
/**
* WebSocket RPC session that maintains gateway connection lifecycle, auth, events, and node invokes.
*/
@@ -134,7 +152,6 @@ class GatewaySession(
private companion object {
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
}
/**
@@ -180,7 +197,6 @@ class GatewaySession(
private val json = Json { ignoreUnknownKeys = true }
private val writeLock = Mutex()
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
@Volatile private var pluginSurfaceUrls: Map<String, String> = emptyMap()
@@ -264,22 +280,33 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
private fun readyConnection(): Connection? = currentConnection?.takeIf { it.isReady() }
internal fun isReady(): Boolean = readyConnection() != null
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
payloadJson: String?,
): Boolean {
val conn = currentConnection ?: return false
): Boolean = sendNodeEventWithOutcome(event, payloadJson) == NodeEventSendOutcome.COMPLETED
internal suspend fun sendNodeEventWithOutcome(
event: String,
payloadJson: String?,
): NodeEventSendOutcome {
val conn = readyConnection() ?: return NodeEventSendOutcome.DISCONNECTED
return try {
conn.request(
"node.event",
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
true
NodeEventSendOutcome.COMPLETED
} catch (_: GatewayRequestNotEnqueued) {
NodeEventSendOutcome.DISCONNECTED
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
false
NodeEventSendOutcome.FAILED
}
}
@@ -290,7 +317,7 @@ class GatewaySession(
timeoutMs: Long = 8_000,
): RpcResult {
val conn =
currentConnection
readyConnection()
?: return RpcResult(
ok = false,
payloadJson = null,
@@ -338,7 +365,7 @@ class GatewaySession(
paramsJson: String?,
timeoutMs: Long = 15_000,
): RpcResult {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val conn = readyConnection() ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
@@ -356,7 +383,7 @@ class GatewaySession(
timeoutMs: Long = 15_000,
onError: (ErrorShape) -> Unit = {},
) {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val conn = readyConnection() ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
@@ -373,6 +400,18 @@ class GatewaySession(
val error: ErrorShape?,
)
private data class ConnectedGateway(
val pluginSurfaceUrls: Map<String, String>,
val mainSessionKey: String?,
val hello: GatewayHelloSummary,
)
private enum class ConnectionState {
CONNECTING,
READY,
CLOSED,
}
private inner class Connection(
val endpoint: GatewayEndpoint,
private val token: String?,
@@ -381,14 +420,19 @@ class GatewaySession(
private val options: GatewayConnectOptions,
val tls: GatewayTlsParams?,
) {
private val connectDeferred = CompletableDeferred<Unit>()
private val state = AtomicReference(ConnectionState.CONNECTING)
private val connectDeferred = CompletableDeferred<ConnectedGateway>()
private val closedDeferred = CompletableDeferred<Unit>()
private val isClosed = AtomicBoolean(false)
private val connectNonceDeferred = CompletableDeferred<String>()
private val client: OkHttpClient = buildClient()
private var socket: WebSocket? = null
private val loggerTag = "OpenClawGateway"
private val incomingMessages = Channel<String>(Channel.UNLIMITED)
// RPC waiters belong to this socket generation. Closing it must not touch a replacement connection.
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
private val pendingLock = Any()
private val messagePumpJob =
scope.launch(Dispatchers.IO) {
for (text in incomingMessages) {
@@ -407,15 +451,11 @@ class GatewaySession(
val remoteAddress: String = formatGatewayAuthority(endpoint.host, endpoint.port)
suspend fun connect() {
suspend fun connect(): ConnectedGateway {
val url = buildGatewayWebSocketUrl(endpoint.host, endpoint.port, tls != null)
val request = Request.Builder().url(url).build()
socket = client.newWebSocket(request, Listener())
try {
connectDeferred.await()
} catch (err: Throwable) {
throw err
}
return connectDeferred.await()
}
suspend fun request(
@@ -424,19 +464,14 @@ class GatewaySession(
timeoutMs: Long,
): RpcResponse {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
val deferred = registerPending(id)
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
return try {
withTimeout(timeoutMs) { deferred.await() }
return withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
pending.remove(id)
throw IllegalStateException("request timeout")
} finally {
pending.remove(id)
}
}
@@ -447,8 +482,7 @@ class GatewaySession(
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
val deferred = registerPending(id)
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
@@ -456,25 +490,43 @@ class GatewaySession(
throw err
}
scope.launch(Dispatchers.IO) {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
pending.remove(id)
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
try {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
} catch (_: CancellationException) {
return@launch
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
} finally {
pending.remove(id)
}
}
}
private fun registerPending(id: String): CompletableDeferred<RpcResponse> {
val deferred = CompletableDeferred<RpcResponse>()
// Registration and the close drain are one lifecycle decision; no waiter may slip between them.
synchronized(pendingLock) {
if (state.get() == ConnectionState.CLOSED) {
throw GatewayRequestNotEnqueued("Gateway closed")
}
pending[id] = deferred
}
return deferred
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
if (socket?.send(jsonString) != true) {
throw IllegalStateException("gateway send failed")
// OkHttp returning false means this frame never entered its outgoing queue.
throw GatewayRequestNotEnqueued("gateway send failed")
}
}
}
@@ -493,13 +545,18 @@ class GatewaySession(
suspend fun awaitClose() = closedDeferred.await()
fun isReady(): Boolean = state.get() == ConnectionState.READY
fun markReady(): Boolean = state.compareAndSet(ConnectionState.CONNECTING, ConnectionState.READY)
fun closeQuietly() {
if (isClosed.compareAndSet(false, true)) {
if (state.getAndSet(ConnectionState.CLOSED) != ConnectionState.CLOSED) {
incomingMessages.close()
messagePumpJob.cancel()
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed"))
}
failPending()
socket?.close(1000, "bye")
socket = null
closedDeferred.complete(Unit)
@@ -555,7 +612,7 @@ class GatewaySession(
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(t)
}
if (isClosed.compareAndSet(false, true)) {
if (state.getAndSet(ConnectionState.CLOSED) != ConnectionState.CLOSED) {
incomingMessages.close()
failPending()
closedDeferred.complete(Unit)
@@ -571,7 +628,7 @@ class GatewaySession(
if (!connectDeferred.isCompleted) {
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
}
if (isClosed.compareAndSet(false, true)) {
if (state.getAndSet(ConnectionState.CLOSED) != ConnectionState.CLOSED) {
incomingMessages.close()
failPending()
closedDeferred.complete(Unit)
@@ -627,8 +684,8 @@ class GatewaySession(
}
throw GatewayConnectFailure(error)
}
handleConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
connectDeferred.complete(Unit)
val connected = parseConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
connectDeferred.complete(connected)
}
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
@@ -680,11 +737,11 @@ class GatewaySession(
deviceAuthStore.saveToken(deviceId, role, token, scopes)
}
private fun handleConnectSuccess(
private fun parseConnectSuccess(
res: RpcResponse,
deviceId: String,
authSource: GatewayConnectAuthSource,
) {
): ConnectedGateway {
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
pendingDeviceTokenRetry = false
@@ -731,21 +788,24 @@ class GatewaySession(
normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null)
?.let { normalized -> surface to normalized }
} ?: emptyList()
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
val nextPluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
val snapshot = obj["snapshot"].asObjectOrNull()
val sessionDefaults =
snapshot
?.get("sessionDefaults")
.asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(
GatewayHelloSummary(
serverName = serverName,
remoteAddress = remoteAddress,
serverVersion = serverVersion,
mainSessionKey = mainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
),
val nextMainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
return ConnectedGateway(
pluginSurfaceUrls = nextPluginSurfaceUrls,
mainSessionKey = nextMainSessionKey,
hello =
GatewayHelloSummary(
serverName = serverName,
remoteAddress = remoteAddress,
serverVersion = serverVersion,
mainSessionKey = nextMainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
),
)
}
@@ -891,7 +951,7 @@ class GatewaySession(
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()),
requestId = normalizeGatewayApprovalRequestId(it["requestId"].asStringOrNull()),
retryable = it["retryable"].asBooleanOrNull() == true,
clientMinProtocol = it["clientMinProtocol"].asIntOrNull(),
clientMaxProtocol = it["clientMaxProtocol"].asIntOrNull(),
@@ -922,11 +982,6 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private fun normalizePairingRequestId(requestId: String?): String? {
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) }
}
private suspend fun awaitConnectNonce(): String =
try {
withTimeout(2_000) { connectNonceDeferred.await() }
@@ -1011,10 +1066,13 @@ class GatewaySession(
}
private fun failPending() {
for ((_, waiter) in pending) {
val waiters =
synchronized(pendingLock) {
pending.values.toList().also { pending.clear() }
}
for (waiter in waiters) {
waiter.cancel()
}
pending.clear()
}
}
@@ -1068,15 +1126,47 @@ class GatewaySession(
target.options,
target.tls,
)
currentConnection = conn
val shouldConnect =
synchronized(lifecycleLock) {
if (desired === target) {
currentConnection = conn
true
} else {
false
}
}
if (!shouldConnect) {
conn.closeQuietly()
return@withContext
}
try {
conn.connect()
val connected = conn.connect()
val published =
synchronized(lifecycleLock) {
if (currentConnection !== conn || desired !== target || !conn.markReady()) {
false
} else {
// Readiness and its metadata must become visible before callbacks flush queued events.
pluginSurfaceUrls = connected.pluginSurfaceUrls
mainSessionKey = connected.mainSessionKey
onConnected(connected.hello)
true
}
}
if (!published) {
conn.closeQuietly()
return@withContext
}
conn.awaitClose()
} finally {
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
// Callback failures and cancellation must close this socket before the loop replaces it.
conn.closeQuietly()
synchronized(lifecycleLock) {
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
}
}
}
@@ -1222,7 +1312,6 @@ class GatewaySession(
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
role = target?.options?.role,
scopes = target?.options?.scopes ?: emptyList(),
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
)
}
@@ -1246,33 +1335,52 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
hasBootstrapToken: Boolean,
role: String?,
scopes: List<String>,
deviceTokenRetryBudgetUsed: Boolean,
pendingDeviceTokenRetry: Boolean,
): Boolean =
when (error.details?.code) {
): Boolean {
val details = error.details
val code = details?.code
if (code == "PAIRING_REQUIRED") {
val pairingDetails = details
return !(
hasBootstrapToken &&
role?.trim() == "node" &&
scopes.isEmpty() &&
pairingDetails.reason == "not-paired" &&
(
pairingDetails.pauseReconnect == false ||
pairingDetails.recommendedNextStep == "wait_then_retry"
)
)
}
// Gateway rate limits last minutes; generic retry advice must not trigger the short reconnect loop.
if (code == "AUTH_RATE_LIMITED") return true
when (details?.recommendedNextStep) {
"wait_then_retry" -> return false
"retry_with_device_token" -> return !pendingDeviceTokenRetry
"update_auth_configuration",
"update_auth_credentials",
"review_auth_configuration",
-> return true
}
return when (code) {
"AUTH_TOKEN_MISSING",
"AUTH_TOKEN_NOT_CONFIGURED",
"AUTH_DEVICE_TOKEN_MISMATCH",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"AUTH_PASSWORD_NOT_CONFIGURED",
"AUTH_SCOPE_MISMATCH",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"PAIRING_REQUIRED" ->
!(
hasBootstrapToken &&
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(
error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry"
)
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
// The first shared-token mismatch may schedule one trusted stored-device-token retry.
// Once no retry is pending, keep the terminal recovery action visible until credentials change.
"AUTH_TOKEN_MISMATCH" -> !pendingDeviceTokenRetry
"PROTOCOL_MISMATCH" -> true
else -> false
}
}
/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */
internal fun buildGatewayWebSocketUrl(
@@ -21,7 +21,6 @@ import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
@@ -44,6 +43,54 @@ import kotlin.math.roundToInt
/**
* CameraX-backed capture service used by gateway camera commands.
*/
internal class CameraClipSession(
private val unbind: () -> Unit,
private val deleteTemporaryFile: (File) -> Unit,
) : AutoCloseable {
private var recording: AutoCloseable? = null
private var temporaryFile: File? = null
private var closed = false
fun ownRecording(recording: AutoCloseable) {
check(!closed) { "camera clip session is closed" }
this.recording = recording
}
fun ownFile(file: File): File {
check(!closed) { "camera clip session is closed" }
check(temporaryFile == null) { "camera clip session already owns a file" }
temporaryFile = file
return file
}
fun transferFile(): File {
check(!closed) { "camera clip session is closed" }
return checkNotNull(temporaryFile) { "camera clip session has no file" }
.also { temporaryFile = null }
}
override fun close() {
if (closed) return
closed = true
var failure: Throwable? = null
fun cleanup(action: () -> Unit) {
try {
action()
} catch (err: Throwable) {
failure?.addSuppressed(err) ?: run { failure = err }
}
}
// Keep teardown symmetric across bind, warmup, recording, finalize, and success exits.
cleanup { recording?.close() }
cleanup(unbind)
temporaryFile?.let { file -> cleanup { deleteTemporaryFile(file) } }
failure?.let { throw it }
}
}
class CameraCaptureManager(
private val context: Context,
) {
@@ -137,7 +184,13 @@ class CameraCaptureManager(
// Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner.
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
val (bytes, orientation) =
try {
capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
} finally {
// The JPEG bytes are self-contained; release CameraX before decoding and recompressing them.
provider.unbind(capture)
}
val decoded =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
@@ -229,87 +282,90 @@ class CameraCaptureManager(
androidx.camera.core.Preview
.Builder()
.build()
// Provide a dummy SurfaceTexture so the preview pipeline activates
val surfaceTexture = android.graphics.SurfaceTexture(0)
surfaceTexture.setDefaultBufferSize(640, 480)
// Allocate the dummy preview surface only after CameraX requests it; its result owns release.
preview.setSurfaceProvider { request ->
val surfaceTexture = android.graphics.SurfaceTexture(0)
surfaceTexture.setDefaultBufferSize(640, 480)
val surface = android.view.Surface(surfaceTexture)
request.provideSurface(surface, context.mainExecutor()) { result ->
request.provideSurface(surface, context.mainExecutor()) {
surface.release()
surfaceTexture.release()
}
}
provider.unbindAll()
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
CameraClipSession(
unbind = { provider.unbind(preview, videoCapture) },
deleteTemporaryFile = { file ->
check(!file.exists() || file.delete()) { "failed to delete temporary camera clip" }
},
).use { session ->
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
// Give camera pipeline time to initialize before recording
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
kotlinx.coroutines.delay(1_500)
// Give camera pipeline time to initialize before recording
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
kotlinx.coroutines.delay(1_500)
val file = File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir)
val outputOptions = FileOutputOptions.Builder(file).build()
val clipFile = session.ownFile(File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir))
val outputOptions = FileOutputOptions.Builder(clipFile).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}")
val recording: Recording =
videoCapture.output
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}.start(context.mainExecutor()) { event ->
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
if (event is VideoRecordEvent.Status) {
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${clipFile.absolutePath}")
val recording =
videoCapture.output
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}.start(context.mainExecutor()) { event ->
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
if (event is VideoRecordEvent.Status) {
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
}
if (event is VideoRecordEvent.Finalize) {
android.util.Log.w(
"CameraCaptureManager",
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
)
finalized.complete(event)
}
}
if (event is VideoRecordEvent.Finalize) {
android.util.Log.w(
"CameraCaptureManager",
"clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}",
)
finalized.complete(event)
}
}
session.ownRecording(recording)
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
try {
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
kotlinx.coroutines.delay(durationMs.toLong())
} finally {
android.util.Log.w("CameraCaptureManager", "clip: stopping recording")
recording.stop()
}
recording.close()
val finalizeEvent =
try {
withTimeout(15_000) { finalized.await() }
} catch (err: Throwable) {
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
withContext(Dispatchers.IO) { file.delete() }
provider.unbindAll()
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
val finalizeEvent =
try {
withTimeout(15_000) { finalized.await() }
} catch (err: kotlinx.coroutines.TimeoutCancellationException) {
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
android.util.Log.e(
"CameraCaptureManager",
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
finalizeEvent.cause,
)
// Check file size for debugging
val fileSize = withContext(Dispatchers.IO) { if (clipFile.exists()) clipFile.length() else -1 }
android.util.Log.e("CameraCaptureManager", "clip: file exists=${clipFile.exists()} size=$fileSize")
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
}
if (finalizeEvent.hasError()) {
android.util.Log.e(
"CameraCaptureManager",
"clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}",
finalizeEvent.cause,
val fileSize = withContext(Dispatchers.IO) { clipFile.length() }
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
FilePayload(
file = session.transferFile(),
durationMs = durationMs.toLong(),
hasAudio = includeAudio,
)
// Check file size for debugging
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
withContext(Dispatchers.IO) { file.delete() }
provider.unbindAll()
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
}
val fileSize = withContext(Dispatchers.IO) { file.length() }
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
provider.unbindAll()
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
}
private fun rotateBitmapByExif(
@@ -3,6 +3,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.hasPhotoReadPermission
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityManager
@@ -322,11 +323,8 @@ class DeviceHandler private constructor(
val photosGranted =
if (!photosEnabled) {
false
} else if (Build.VERSION.SDK_INT >= 33) {
// Android 13 split media permissions; earlier versions use external storage.
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
hasPhotoReadPermission(appContext)
}
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
val notificationsGranted =
@@ -428,14 +426,18 @@ class DeviceHandler private constructor(
put(
"contacts",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CONTACTS),
granted =
hasPermission(Manifest.permission.READ_CONTACTS) &&
hasPermission(Manifest.permission.WRITE_CONTACTS),
promptableWhenDenied = true,
),
)
put(
"calendar",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALENDAR),
granted =
hasPermission(Manifest.permission.READ_CALENDAR) &&
hasPermission(Manifest.permission.WRITE_CALENDAR),
promptableWhenDenied = true,
),
)
@@ -163,6 +163,7 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawTalkCommand.PttOnce.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawCameraCommand.List.rawValue,
@@ -104,10 +104,10 @@ class InvokeDispatcher(
message = "INVALID_REQUEST: unknown command",
)
if (spec.requiresForeground && !isForeground()) {
// Canvas, camera, and screen-backed commands need an active Activity/WebView surface.
// Foreground-only commands need an active Activity surface before touching UI or capture APIs.
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
message = "NODE_BACKGROUND_UNAVAILABLE: command requires foreground",
)
}
availabilityError(spec.availability)?.let { return it }
@@ -1,17 +1,15 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import ai.openclaw.app.hasPhotoReadPermission
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 androidx.core.graphics.scale
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
@@ -56,16 +54,7 @@ internal interface PhotosDataSource {
}
private object SystemPhotosDataSource : PhotosDataSource {
/** Checks the API-specific image read permission used by MediaStore image access. */
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 hasPermission(context: Context): Boolean = hasPhotoReadPermission(context)
override fun latest(
context: Context,
@@ -2,6 +2,7 @@ package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.ui.mobileCardSurface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
@@ -51,6 +52,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -65,16 +67,16 @@ private enum class ConnectInputMode {
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val statusText = gatewayConnectionDisplay.statusText
val gatewayConnectionProblem = gatewayConnectionDisplay.problem
val isConnected = gatewayConnectionDisplay.isConnected
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val manualEnabled by viewModel.manualEnabled.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
var advancedOpen by rememberSaveable { mutableStateOf(false) }
@@ -92,6 +94,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) }
var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) }
var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) }
var tokenInput by remember { mutableStateOf("") }
var passwordInput by rememberSaveable { mutableStateOf("") }
var validationText by rememberSaveable { mutableStateOf<String?>(null) }
@@ -100,13 +103,17 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
title = { Text(stringResource(R.string.trust_this_gateway), style = mobileHeadline, color = mobileText) },
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
stringResource(R.string.gateway_trust_first_seen, prompt.fingerprintSha256)
} else {
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
stringResource(
R.string.gateway_trust_changed,
prompt.previousFingerprintSha256,
prompt.fingerprintSha256,
)
}
Text(
message,
@@ -119,7 +126,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
) {
Text("Trust and continue")
Text(stringResource(R.string.trust_and_continue))
}
},
dismissButton = {
@@ -127,7 +134,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
) {
Text("Cancel")
Text(stringResource(R.string.cancel))
}
},
)
@@ -158,9 +165,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
Text(stringResource(R.string.gateway_connection), style = mobileTitle1, color = mobileText)
Text(
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
if (isConnected) {
stringResource(R.string.connected_gateway_ready)
} else {
stringResource(R.string.connect_gateway_get_started)
},
style = mobileCallout,
color = mobileTextSecondary,
)
@@ -191,7 +202,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.endpoint), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
}
}
@@ -213,7 +224,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.status), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
}
}
@@ -238,7 +249,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
Text(stringResource(R.string.disconnect), style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
}
} else {
Button(
@@ -249,8 +260,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
return@Button
}
val config =
resolveGatewayConnectConfig(
val plan =
resolveGatewayConnectPlan(
useSetupCode = inputMode == ConnectInputMode.SetupCode,
setupCode = setupCode,
savedManualHost = manualHost,
@@ -259,12 +270,12 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
fallbackBootstrapToken = gatewayBootstrapToken,
fallbackToken = gatewayToken,
fallbackPassword = passwordInput,
bootstrapTokenInput = "",
tokenInput = tokenInput,
passwordInput = passwordInput,
)
if (config == null) {
if (plan == null) {
validationText =
if (inputMode == ConnectInputMode.SetupCode) {
val parsedSetup = decodeGatewaySetupCode(setupCode)
@@ -289,15 +300,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = inputMode == ConnectInputMode.SetupCode,
)
viewModel.saveGatewayConfigAndConnect(plan)
tokenInput = ""
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
@@ -307,7 +311,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
contentColor = Color.White,
),
) {
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
Text(stringResource(R.string.connect_gateway), style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
}
}
@@ -354,7 +358,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
) {
Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Copy Report for Claw", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
Text(stringResource(R.string.copy_report_for_claw), style = mobileCallout.copy(fontWeight = FontWeight.Bold))
}
}
}
@@ -373,7 +377,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Advanced controls", style = mobileHeadline, color = mobileText)
Text(stringResource(R.string.advanced_controls), style = mobileHeadline, color = mobileText)
Text("Setup code, endpoint, TLS, token, password, onboarding.", style = mobileCaption1, color = mobileTextSecondary)
}
Icon(
@@ -395,15 +399,15 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Connection method", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.connection_method), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MethodChip(
label = "Setup Code",
label = stringResource(R.string.setup_code),
active = inputMode == ConnectInputMode.SetupCode,
onClick = { inputMode = ConnectInputMode.SetupCode },
)
MethodChip(
label = "Manual",
label = stringResource(R.string.manual),
active = inputMode == ConnectInputMode.Manual,
onClick = { inputMode = ConnectInputMode.Manual },
)
@@ -419,14 +423,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
if (inputMode == ConnectInputMode.SetupCode) {
Text("Setup Code", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.setup_code), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = setupCode,
onValueChange = {
setupCode = it
validationText = null
},
placeholder = { Text("Paste setup code", style = mobileBody, color = mobileTextTertiary) },
placeholder = { Text(stringResource(R.string.paste_setup_code), style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
@@ -460,7 +464,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Text("Host", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.host), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = manualHostInput,
onValueChange = {
@@ -502,7 +506,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Use TLS", style = mobileHeadline, color = mobileText)
Text(stringResource(R.string.use_tls), style = mobileHeadline, color = mobileText)
Text(
"Turn this on for Tailscale or public hosts. Private LAN ws:// remains supported.",
style = mobileCallout,
@@ -525,11 +529,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
)
}
Text("Token (optional)", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
OutlinedTextField(
value = gatewayToken,
onValueChange = { viewModel.setGatewayToken(it) },
placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) },
value = tokenInput,
onValueChange = { tokenInput = it },
placeholder = { Text("Leave blank to keep saved token", style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
@@ -546,7 +550,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
OutlinedTextField(
value = passwordInput,
onValueChange = { passwordInput = it },
placeholder = { Text("password", style = mobileBody, color = mobileTextTertiary) },
placeholder = { Text(stringResource(R.string.password), style = mobileBody, color = mobileTextTertiary) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
@@ -563,7 +567,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
HorizontalDivider(color = mobileBorder)
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
Text(stringResource(R.string.run_onboarding_again), style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
}
}
}
@@ -648,7 +652,11 @@ private fun CommandBlock(command: String) {
private fun EndpointPreview(endpoint: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
HorizontalDivider(color = mobileBorder)
Text("Resolved endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
Text(
stringResource(R.string.resolved_endpoint),
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileTextSecondary,
)
Text(endpoint, style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileText)
HorizontalDivider(color = mobileBorder)
}
@@ -36,6 +36,19 @@ internal data class GatewayConnectConfig(
val password: String,
)
/** How a connection attempt may update credentials already owned by the runtime. */
internal enum class GatewaySavedAuthAction {
PRESERVE,
REPLACE_ENDPOINT,
REPLACE_SETUP,
}
/** Endpoint plus the credential ownership decision applied by MainViewModel. */
internal data class GatewayConnectPlan(
val config: GatewayConnectConfig,
val savedAuthAction: GatewaySavedAuthAction,
)
/** Validation reason used by setup, QR, and manual endpoint copy. */
internal enum class GatewayEndpointValidationError {
INVALID_URL,
@@ -67,37 +80,38 @@ private const val remoteGatewaySecurityRule =
private const val remoteGatewaySecurityFix =
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
/** Resolves setup-code or manual UI fields into a connection config. */
/** Resolves setup-code or manual UI fields without reading stored credentials. */
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
fallbackBootstrapToken: String,
fallbackToken: String,
fallbackPassword: String,
bootstrapTokenInput: String,
tokenInput: String,
passwordInput: String,
): GatewayConnectConfig? {
if (useSetupCode) {
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
val setupBootstrapToken =
setup.bootstrapToken
?.trim()
.orEmpty()
.ifEmpty { bootstrapTokenInput.trim() }
// Bootstrap setup codes intentionally suppress stale shared credentials;
// the bootstrap token owns the first authenticated pairing exchange.
val sharedToken =
when {
!setup.token.isNullOrBlank() -> setup.token.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackToken.trim()
else -> tokenInput.trim()
}
val sharedPassword =
when {
!setup.password.isNullOrBlank() -> setup.password.trim()
setupBootstrapToken.isNotEmpty() -> ""
else -> fallbackPassword.trim()
setupBootstrapToken.isNotEmpty() || sharedToken.isNotEmpty() -> ""
else -> passwordInput.trim()
}
return GatewayConnectConfig(
host = parsed.host,
@@ -111,26 +125,70 @@ internal fun resolveGatewayConnectConfig(
val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null
val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let { parseGatewayEndpointResult(it).config }
val preserveBootstrapToken =
savedManualEndpoint != null &&
savedManualEndpoint.host == parsed.host &&
savedManualEndpoint.port == parsed.port &&
savedManualEndpoint.tls == parsed.tls &&
fallbackToken.isBlank() &&
fallbackPassword.isBlank()
val token = tokenInput.trim()
val bootstrapToken = bootstrapTokenInput.trim().takeIf { token.isEmpty() }.orEmpty()
val password = passwordInput.trim().takeIf { token.isEmpty() && bootstrapToken.isEmpty() }.orEmpty()
return GatewayConnectConfig(
host = parsed.host,
port = parsed.port,
tls = parsed.tls,
bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "",
token = fallbackToken.trim(),
password = fallbackPassword.trim(),
bootstrapToken = bootstrapToken,
token = token,
password = password,
)
}
/**
* Produces one closed endpoint/auth plan. Blank auth fields preserve secrets
* only for the saved endpoint; neither Compose nor this resolver reads them.
*/
internal fun resolveGatewayConnectPlan(
useSetupCode: Boolean,
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHostInput: String,
manualPortInput: String,
manualTlsInput: Boolean,
tokenInput: String,
bootstrapTokenInput: String,
passwordInput: String,
): GatewayConnectPlan? {
val config =
resolveGatewayConnectConfig(
useSetupCode = useSetupCode,
setupCode = setupCode,
manualHostInput = manualHostInput,
manualPortInput = manualPortInput,
manualTlsInput = manualTlsInput,
tokenInput = tokenInput,
bootstrapTokenInput = bootstrapTokenInput,
passwordInput = passwordInput,
) ?: return null
if (useSetupCode) {
return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP)
}
if (config.bootstrapToken.isNotEmpty()) {
// Bootstrap auth requests a fresh pairing exchange. Retained role tokens
// would otherwise win before the bootstrap credential is attempted.
return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP)
}
val savedManualEndpoint =
composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls)
?.let { parseGatewayEndpointResult(it).config }
val action =
if (savedManualEndpoint?.sameEndpoint(config) == true) {
GatewaySavedAuthAction.PRESERVE
} else {
GatewaySavedAuthAction.REPLACE_ENDPOINT
}
return GatewayConnectPlan(config, action)
}
private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
@@ -164,7 +222,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
}
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val port = gatewayPort(uri.port, defaultPort) ?: return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =
if (port == displayPort && defaultPort == displayPort) {
@@ -244,6 +302,16 @@ internal fun gatewayEndpointValidationMessage(
}
}
private fun gatewayPort(
port: Int,
defaultPort: Int,
): Int? =
when {
port == -1 -> defaultPort
port in 1..65535 -> port
else -> null
}
/** Builds a URL from manual host/port/tls fields for shared endpoint parsing. */
internal fun composeGatewayManualUrl(
hostInput: String,
@@ -252,6 +320,14 @@ internal fun composeGatewayManualUrl(
): String? {
val host = hostInput.trim()
if (host.isEmpty()) return null
// A pasted endpoint is already a complete authority; its scheme and port
// must not be silently replaced by stale values from the separate controls.
if (host.contains("://")) {
val parsed = parseGatewayEndpointResult(host)
return host.takeUnless { parsed.error == GatewayEndpointValidationError.INVALID_URL }
}
val bareHost = host.trimEnd('/')
if (bareHost.isEmpty() || bareHost.contains('/')) return null
val portTrimmed = portInput.trim()
val port =
if (portTrimmed.isEmpty()) {
@@ -261,7 +337,7 @@ internal fun composeGatewayManualUrl(
}
if (port !in 1..65535) return null
val scheme = if (tls) "https" else "http"
return "$scheme://$host:$port"
return "$scheme://${ai.openclaw.app.gateway.formatGatewayAuthority(bareHost, port)}"
}
private fun parseJsonObject(input: String): JsonObject? = runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
@@ -1,6 +1,10 @@
package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeCapabilityApproval
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -26,12 +30,100 @@ internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
return lower != "offline" && !lower.contains("connecting")
}
/** Resolves the best non-secret endpoint label available to diagnostics surfaces. */
internal fun gatewayDiagnosticsEndpoint(
remoteAddress: String?,
manualHost: String,
manualPort: Int,
manualTls: Boolean,
): String {
remoteAddress?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
return composeGatewayManualUrl(manualHost, manualPort.toString(), manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } ?: "Not set"
}
/** Detects pairing/approval status text so UI can offer pairing-specific actions. */
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("pair") || lower.contains("approve")
}
/** Maps structured gateway auth failures to the compact labels used by status surfaces. */
internal fun gatewayAuthRecoveryLabel(problem: GatewayConnectionProblem?): String? {
val kind =
when (problem?.code) {
"AUTH_BOOTSTRAP_TOKEN_INVALID" -> GatewayAuthRecoveryLabelKind.SETUP_CODE_EXPIRED
"AUTH_TOKEN_MISSING" -> GatewayAuthRecoveryLabelKind.TOKEN_NEEDED
"AUTH_TOKEN_NOT_CONFIGURED" -> GatewayAuthRecoveryLabelKind.TOKEN_NOT_CONFIGURED
"AUTH_PASSWORD_MISSING" -> GatewayAuthRecoveryLabelKind.PASSWORD_NEEDED
"AUTH_PASSWORD_MISMATCH" -> GatewayAuthRecoveryLabelKind.PASSWORD_INVALID
"AUTH_PASSWORD_NOT_CONFIGURED" -> GatewayAuthRecoveryLabelKind.PASSWORD_NOT_CONFIGURED
"AUTH_SCOPE_MISMATCH" -> GatewayAuthRecoveryLabelKind.ACCESS_NEEDS_REVIEW
"AUTH_TOKEN_MISMATCH",
"AUTH_DEVICE_TOKEN_MISMATCH",
-> GatewayAuthRecoveryLabelKind.SAVED_AUTH_INVALID
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> GatewayAuthRecoveryLabelKind.DEVICE_IDENTITY_REQUIRED
else -> return null
}
return gatewayAuthRecoveryLabel(kind)
}
private enum class GatewayAuthRecoveryLabelKind {
SETUP_CODE_EXPIRED,
TOKEN_NEEDED,
TOKEN_NOT_CONFIGURED,
PASSWORD_NEEDED,
PASSWORD_INVALID,
PASSWORD_NOT_CONFIGURED,
ACCESS_NEEDS_REVIEW,
SAVED_AUTH_INVALID,
DEVICE_IDENTITY_REQUIRED,
}
private fun gatewayAuthRecoveryLabel(kind: GatewayAuthRecoveryLabelKind): String =
when (kind) {
GatewayAuthRecoveryLabelKind.SETUP_CODE_EXPIRED -> "Setup code expired"
GatewayAuthRecoveryLabelKind.TOKEN_NEEDED -> "Gateway token needed"
GatewayAuthRecoveryLabelKind.TOKEN_NOT_CONFIGURED -> "Gateway token not configured"
GatewayAuthRecoveryLabelKind.PASSWORD_NEEDED -> "Gateway password needed"
GatewayAuthRecoveryLabelKind.PASSWORD_INVALID -> "Gateway password invalid"
GatewayAuthRecoveryLabelKind.PASSWORD_NOT_CONFIGURED -> "Gateway password not configured"
GatewayAuthRecoveryLabelKind.ACCESS_NEEDS_REVIEW -> "Gateway access needs review"
GatewayAuthRecoveryLabelKind.SAVED_AUTH_INVALID -> "Saved auth invalid"
GatewayAuthRecoveryLabelKind.DEVICE_IDENTITY_REQUIRED -> "Device identity required"
}
/** Returns the exact host command for one node's approval state when available. */
internal fun gatewayNodeApprovalCommand(
state: GatewayNodeApprovalState,
requestId: String?,
): String? =
when (state) {
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
-> normalizeGatewayApprovalRequestId(requestId)?.let { "openclaw nodes approve $it" } ?: "openclaw nodes status"
GatewayNodeApprovalState.Unapproved -> "openclaw nodes status"
GatewayNodeApprovalState.Loading,
GatewayNodeApprovalState.Unsupported,
GatewayNodeApprovalState.Approved,
-> null
}
internal fun gatewayNodeApprovalCommand(approval: GatewayNodeCapabilityApproval): String? =
when (approval) {
is GatewayNodeCapabilityApproval.PendingApproval ->
gatewayNodeApprovalCommand(GatewayNodeApprovalState.PendingApproval, approval.requestId)
is GatewayNodeCapabilityApproval.PendingReapproval ->
gatewayNodeApprovalCommand(GatewayNodeApprovalState.PendingReapproval, approval.requestId)
GatewayNodeCapabilityApproval.Unapproved ->
gatewayNodeApprovalCommand(GatewayNodeApprovalState.Unapproved, requestId = null)
GatewayNodeCapabilityApproval.Loading,
GatewayNodeCapabilityApproval.Unsupported,
GatewayNodeCapabilityApproval.Approved,
-> null
}
/** Builds the copyable support prompt with device, endpoint, and exact status context. */
internal fun buildGatewayDiagnosticsReport(
screen: String,
@@ -41,10 +41,10 @@ internal fun HealthLogsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val isConnected = gatewayConnectionDisplay.isConnected
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val modelCount by viewModel.modelCatalog.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val talkStatus by viewModel.talkModeStatusText.collectAsState()
@@ -82,7 +82,7 @@ internal fun HealthLogsSettingsScreen(
),
)
HealthStatusPanel(
gateway = statusText,
gateway = gatewayConnectionDisplay.statusText,
node = if (isNodeConnected) "Online" else "Waiting",
chat = if (chatHealthOk) "Ready" else "Needs connection",
models = "${modelCount.size} available",
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material3.HorizontalDivider
@@ -28,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/** Settings screen for gateway nodes, paired devices, and pending pairing requests. */
@@ -99,7 +101,26 @@ private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!summary.devicePairingAvailable) {
ClawPanel {
Text(text = "Device pairing admin needs elevated access. Connected nodes still work.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = devicePairingAdminUnavailableText(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
val approvalCommands = summary.nodes.mapNotNull(::nodeApprovalCommandRow)
if (approvalCommands.isNotEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "Node approval required", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Run on the Gateway host:", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
approvalCommands.forEach { (label, command) ->
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
SelectionContainer {
Text(
text = command,
style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace),
color = ClawTheme.colors.text,
)
}
}
}
}
}
if (summary.pendingDevices.isNotEmpty()) {
@@ -135,6 +156,11 @@ private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
}
}
private fun nodeApprovalCommandRow(node: GatewayNodeSummary): Pair<String, String>? {
val command = gatewayNodeApprovalCommand(node.approvalState, node.pendingRequestId) ?: return null
return (node.displayName ?: node.id) to command
}
@Composable
private fun NodesSection(
title: String,
@@ -246,6 +272,10 @@ private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): Strin
-> null
}
internal fun devicePairingAdminUnavailableText(): String =
"This gateway sign-in can list connected nodes, but it cannot approve new phone pairing. " +
"Pair new phones from a gateway admin session. Node capability approval is separate and still uses nodes approve <request id>."
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")
@@ -1,12 +1,15 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeCapabilityApproval
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
import ai.openclaw.app.hasPhotoReadPermission
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.photoReadPermissionsForRequest
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawErrorState
import ai.openclaw.app.ui.design.ClawListItem
@@ -103,6 +106,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -136,24 +140,28 @@ fun OnboardingFlow(
val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
ClawDesignTheme(dark = onboardingDark) {
val context = LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val statusText = gatewayConnectionDisplay.statusText
val gatewayConnectionProblem = gatewayConnectionDisplay.problem
val isConnected = gatewayConnectionDisplay.isConnected
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val nodeCapabilityApprovalState by viewModel.nodeCapabilityApprovalState.collectAsState()
val nodeCapabilityApproval by viewModel.nodeCapabilityApproval.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val savedToken by viewModel.gatewayToken.collectAsState()
val savedManualHost by viewModel.manualHost.collectAsState()
val savedManualPort by viewModel.manualPort.collectAsState()
val savedManualTls by viewModel.manualTls.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
val ready =
canFinishOnboarding(
isConnected = isConnected,
isNodeConnected = isNodeConnected,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
nodeCapabilityApproval = nodeCapabilityApproval,
)
var step by rememberSaveable { mutableStateOf(OnboardingStep.Welcome) }
@@ -182,29 +190,21 @@ fun OnboardingFlow(
val permissionState = rememberPermissionState(context = context, viewModel = viewModel)
fun connectToGatewayConfig(
config: GatewayConnectConfig,
resetSetupAuth: Boolean,
) {
fun connectToGatewayPlan(plan: GatewayConnectPlan) {
setupError = null
attemptedGatewayName = null
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = resetSetupAuth,
)
viewModel.saveGatewayConfigAndConnect(plan)
step = OnboardingStep.Recovery
}
fun resolveCurrentGatewayConfig(setupCodeValue: String = setupCode): GatewayConnectConfig? =
resolveOnboardingGatewayConnectConfig(
fun resolveCurrentGatewayPlan(setupCodeValue: String = setupCode): GatewayConnectPlan? =
resolveOnboardingGatewayConnectPlan(
setupCode = setupCodeValue,
savedManualHost = savedManualHost,
savedManualPort = savedManualPort.toString(),
savedManualTls = savedManualTls,
manualHost = manualHost,
manualPort = manualPort,
manualTls = manualTls,
@@ -212,6 +212,40 @@ fun OnboardingFlow(
password = password,
)
fun scanGatewaySetupCode() {
setupError = null
qrScanner
.startScan()
.addOnSuccessListener { barcode ->
val scanned = resolveScannedSetupCodeResult(barcode.rawValue.orEmpty())
val scannedSetupCode = scanned.setupCode
if (scannedSetupCode == null) {
setupError =
gatewayEndpointValidationMessage(
scanned.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
step = OnboardingStep.Gateway
return@addOnSuccessListener
}
val plan = resolveCurrentGatewayPlan(setupCodeValue = scannedSetupCode)
if (plan == null) {
setupError =
gatewayEndpointValidationMessage(
GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
step = OnboardingStep.Gateway
return@addOnSuccessListener
}
setupCode = scannedSetupCode
connectToGatewayPlan(plan)
}.addOnFailureListener {
setupError = "Could not open the scanner."
step = OnboardingStep.Gateway
}
}
LaunchedEffect(startAtGatewaySetup) {
if (startAtGatewaySetup) {
step = OnboardingStep.Gateway
@@ -242,22 +276,38 @@ fun OnboardingFlow(
AlertDialog(
onDismissRequest = viewModel::declineGatewayTrustPrompt,
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
text = {
title = {
Text(
"Verify the certificate fingerprint before continuing.\n\n${prompt.fingerprintSha256}",
stringResource(R.string.trust_this_gateway),
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
},
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
stringResource(R.string.gateway_trust_first_seen, prompt.fingerprintSha256)
} else {
stringResource(
R.string.gateway_trust_changed,
prompt.previousFingerprintSha256,
prompt.fingerprintSha256,
)
}
Text(
message,
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
},
confirmButton = {
TextButton(onClick = viewModel::acceptGatewayTrustPrompt) {
Text("Trust")
Text(stringResource(R.string.trust_and_continue))
}
},
dismissButton = {
TextButton(onClick = viewModel::declineGatewayTrustPrompt) {
Text("Cancel")
Text(stringResource(R.string.cancel))
}
},
)
@@ -285,34 +335,7 @@ fun OnboardingFlow(
discoveryStarted = runtimeInitialized,
error = setupError,
onBack = { step = OnboardingStep.Welcome },
onScan = {
setupError = null
qrScanner
.startScan()
.addOnSuccessListener { barcode ->
val scanned = resolveScannedSetupCodeResult(barcode.rawValue.orEmpty())
val scannedSetupCode = scanned.setupCode
if (scannedSetupCode == null) {
setupError =
gatewayEndpointValidationMessage(
scanned.error ?: GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
return@addOnSuccessListener
}
val config = resolveCurrentGatewayConfig(setupCodeValue = scannedSetupCode)
if (config == null) {
setupError =
gatewayEndpointValidationMessage(
GatewayEndpointValidationError.INVALID_URL,
GatewayEndpointInputSource.QR_SCAN,
)
return@addOnSuccessListener
}
setupCode = scannedSetupCode
connectToGatewayConfig(config, resetSetupAuth = true)
}.addOnFailureListener { setupError = "Could not open the scanner." }
},
onScan = ::scanGatewaySetupCode,
onSetupCodeChange = {
setupCode = it
setupError = null
@@ -337,12 +360,12 @@ fun OnboardingFlow(
step = OnboardingStep.Recovery
},
onPair = {
val config = resolveCurrentGatewayConfig()
if (config == null) {
val plan = resolveCurrentGatewayPlan()
if (plan == null) {
setupError = "Enter a setup code or a valid gateway URL."
return@GatewaySetupScreen
}
connectToGatewayConfig(config, resetSetupAuth = true)
connectToGatewayPlan(plan)
},
)
OnboardingStep.Recovery ->
@@ -353,15 +376,16 @@ fun OnboardingFlow(
attemptedGatewayName = attemptedGatewayName,
remoteAddress = remoteAddress,
ready = ready,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
nodeCapabilityApproval = nodeCapabilityApproval,
gatewayConnectionProblem = gatewayConnectionProblem,
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
onBack = { step = OnboardingStep.Gateway },
onRetry = {
val config = resolveCurrentGatewayConfig() ?: return@GatewayRecoveryScreen
connectToGatewayConfig(config, resetSetupAuth = false)
val plan = resolveCurrentGatewayPlan() ?: return@GatewayRecoveryScreen
connectToGatewayPlan(plan.copy(savedAuthAction = GatewaySavedAuthAction.PRESERVE))
},
onEdit = { step = OnboardingStep.Gateway },
onScan = ::scanGatewaySetupCode,
onContinue = { step = OnboardingStep.Permissions },
)
OnboardingStep.Permissions ->
@@ -535,20 +559,24 @@ private fun GatewaySetupScreen(
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(9.dp)) {
item {
OnboardingHeader(title = "Gateway Setup", subtitle = "Connect to your Gateway", onBack = onBack)
OnboardingHeader(
title = stringResource(R.string.gateway_setup),
subtitle = stringResource(R.string.connect_to_gateway),
onBack = onBack,
)
}
item {
GatewayOption(
icon = Icons.Default.QrCode2,
title = "Scan setup code",
subtitle = "Use your Gateway QR or setup code",
title = stringResource(R.string.scan_setup_code),
subtitle = stringResource(R.string.use_gateway_qr),
onClick = onScan,
)
}
item {
GatewayOption(
icon = Icons.Default.WifiTethering,
title = "Nearby gateway",
title = stringResource(R.string.nearby_gateway),
subtitle = nearbyGateway.subtitle,
status = nearbyGateway.status,
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
@@ -557,8 +585,8 @@ private fun GatewaySetupScreen(
item {
GatewayOption(
icon = Icons.Default.Link,
title = "Enter gateway URL",
subtitle = "Connect using a manual URL",
title = stringResource(R.string.enter_gateway_url),
subtitle = stringResource(R.string.connect_manual_url),
onClick = { advancedOpen = true },
)
}
@@ -618,12 +646,13 @@ private fun GatewayRecoveryScreen(
attemptedGatewayName: String?,
remoteAddress: String?,
ready: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
gatewayConnectionProblem: GatewayConnectionProblem?,
connectSettling: Boolean,
onBack: () -> Unit,
onRetry: () -> Unit,
onEdit: () -> Unit,
onScan: () -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -632,14 +661,15 @@ private fun GatewayRecoveryScreen(
ready = ready,
statusText = statusText,
connectSettling = connectSettling,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
nodeCapabilityApproval = nodeCapabilityApproval,
gatewayConnectionProblem = gatewayConnectionProblem,
)
val primaryAction = gatewayRecoveryPrimaryAction(ready, gatewayConnectionProblem)
val context = LocalContext.current
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
OnboardingHeader(title = "Gateway Recovery", onBack = onBack)
OnboardingHeader(title = stringResource(R.string.gateway_recovery), onBack = onBack)
Spacer(modifier = Modifier.height(12.dp))
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
@@ -648,6 +678,7 @@ private fun GatewayRecoveryScreen(
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.WifiTethering
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
GatewayRecoveryUiState.AuthenticationRequired -> Icons.Default.Security
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
@@ -659,6 +690,7 @@ private fun GatewayRecoveryScreen(
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.AuthenticationRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
@@ -683,13 +715,13 @@ private fun GatewayRecoveryScreen(
ready = ready,
remoteAddress = remoteAddress,
statusText = statusText,
nodeCapabilityApprovalState = nodeCapabilityApprovalState,
nodeCapabilityApproval = nodeCapabilityApproval,
gatewayConnectionProblem = gatewayConnectionProblem,
),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem)?.let { command ->
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
}
ClawStatusPill(
@@ -698,6 +730,7 @@ private fun GatewayRecoveryScreen(
GatewayRecoveryUiState.Connected -> "Healthy"
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> "Node approval"
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
GatewayRecoveryUiState.AuthenticationRequired -> "Needs authentication"
GatewayRecoveryUiState.Pairing -> "Pairing"
GatewayRecoveryUiState.Finishing -> "Connecting"
GatewayRecoveryUiState.Failed -> "Needs attention"
@@ -707,6 +740,7 @@ private fun GatewayRecoveryScreen(
GatewayRecoveryUiState.Connected -> ClawStatus.Success
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawStatus.Warning
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
GatewayRecoveryUiState.AuthenticationRequired -> ClawStatus.Warning
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
GatewayRecoveryUiState.Failed -> ClawStatus.Warning
@@ -717,13 +751,44 @@ private fun GatewayRecoveryScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPrimaryButton(
text = if (ready) "Continue" else "Retry connection",
icon = if (ready) Icons.Default.CheckCircle else Icons.Default.Refresh,
onClick = if (ready) onContinue else onRetry,
text = gatewayRecoveryPrimaryActionLabel(primaryAction),
icon =
when (primaryAction) {
GatewayRecoveryPrimaryAction.Continue -> Icons.Default.CheckCircle
GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> Icons.Default.QrCode2
GatewayRecoveryPrimaryAction.EditConnection -> Icons.Default.Edit
GatewayRecoveryPrimaryAction.RetryConnection -> Icons.Default.Refresh
},
onClick =
when (primaryAction) {
GatewayRecoveryPrimaryAction.Continue -> onContinue
GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> onScan
GatewayRecoveryPrimaryAction.EditConnection -> onEdit
GatewayRecoveryPrimaryAction.RetryConnection -> onRetry
},
modifier = Modifier.fillMaxWidth(),
)
OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit)
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready, gatewayConnectionProblem) })
if (
primaryAction != GatewayRecoveryPrimaryAction.ScanFreshSetupCode &&
primaryAction != GatewayRecoveryPrimaryAction.EditConnection
) {
OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit)
}
OutlinedAction(
title = "Copy diagnostic",
icon = Icons.Default.ContentCopy,
onClick = {
copyGatewayDiagnostic(
context,
statusText,
serverName,
remoteAddress,
ready,
nodeCapabilityApproval,
gatewayConnectionProblem,
)
},
)
}
}
}
@@ -924,7 +989,9 @@ private fun PermissionTopBar(onBack: () -> Unit) {
AlertDialog(
onDismissRequest = { showHelp = false },
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Permissions", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
title = {
Text(stringResource(R.string.permissions), style = ClawTheme.type.section, color = ClawTheme.colors.text)
},
text = {
Text(
"Choose what this phone can share with OpenClaw. You can change these later in Settings.",
@@ -934,7 +1001,7 @@ private fun PermissionTopBar(onBack: () -> Unit) {
},
confirmButton = {
TextButton(onClick = { showHelp = false }) {
Text("Done")
Text(stringResource(R.string.done))
}
},
)
@@ -1054,6 +1121,10 @@ internal enum class GatewayRecoveryUiState(
title = "Pairing Gateway",
message = "Approve this phone on the gateway.\nThen retry the connection.",
),
AuthenticationRequired(
title = "Authentication needed",
message = "Update this Gateway connection to continue.",
),
NodeCapabilityApprovalPending(
title = "Node Approval Pending",
message = "Gateway pairing worked.\nApprove this phone's node capabilities from an operator UI.",
@@ -1072,6 +1143,56 @@ internal enum class GatewayRecoveryUiState(
),
}
internal enum class GatewayRecoveryPrimaryAction {
Continue,
ScanFreshSetupCode,
EditConnection,
RetryConnection,
}
internal fun gatewayRecoveryPrimaryActionLabel(action: GatewayRecoveryPrimaryAction): String =
when (action) {
GatewayRecoveryPrimaryAction.Continue -> "Continue"
GatewayRecoveryPrimaryAction.ScanFreshSetupCode -> "Scan fresh setup code"
GatewayRecoveryPrimaryAction.EditConnection -> "Edit connection"
GatewayRecoveryPrimaryAction.RetryConnection -> "Retry connection"
}
/** Selects the action that can actually recover the current structured gateway failure. */
internal fun gatewayRecoveryPrimaryAction(
ready: Boolean,
problem: GatewayConnectionProblem?,
): GatewayRecoveryPrimaryAction =
when {
ready -> GatewayRecoveryPrimaryAction.Continue
problem?.code == "AUTH_BOOTSTRAP_TOKEN_INVALID" -> GatewayRecoveryPrimaryAction.ScanFreshSetupCode
gatewayProblemNeedsCredentialUpdate(problem) -> GatewayRecoveryPrimaryAction.EditConnection
else -> GatewayRecoveryPrimaryAction.RetryConnection
}
private fun gatewayProblemNeedsCredentialUpdate(problem: GatewayConnectionProblem?): Boolean =
when (problem?.code) {
"AUTH_DEVICE_TOKEN_MISMATCH",
"AUTH_TOKEN_MISMATCH",
"AUTH_SCOPE_MISMATCH",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_TOKEN_MISSING",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
else ->
problem?.recommendedNextStep == "update_auth_credentials"
}
private fun gatewayProblemNeedsAuthenticationRecovery(problem: GatewayConnectionProblem?): Boolean =
gatewayProblemNeedsCredentialUpdate(problem) ||
problem?.code == "AUTH_BOOTSTRAP_TOKEN_INVALID" ||
problem?.code == "AUTH_TOKEN_NOT_CONFIGURED" ||
problem?.code == "AUTH_PASSWORD_NOT_CONFIGURED" ||
problem?.recommendedNextStep == "update_auth_configuration" ||
problem?.recommendedNextStep == "review_auth_configuration"
internal data class NearbyGatewayUiState(
val subtitle: String,
val status: String?,
@@ -1115,19 +1236,19 @@ internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
connectSettling: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): GatewayRecoveryUiState =
when {
ready -> GatewayRecoveryUiState.Connected
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved -> GatewayRecoveryUiState.NodeCapabilityApprovalPending
nodeCapabilityApproval.needsApproval() -> GatewayRecoveryUiState.NodeCapabilityApprovalPending
gatewayConnectionProblem?.isPairingRequired == true &&
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
gatewayProblemNeedsAuthenticationRecovery(gatewayConnectionProblem) ->
GatewayRecoveryUiState.AuthenticationRequired
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading -> GatewayRecoveryUiState.Finishing
nodeCapabilityApproval == GatewayNodeCapabilityApproval.Loading -> GatewayRecoveryUiState.Finishing
connectSettling -> GatewayRecoveryUiState.Finishing
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
@@ -1152,27 +1273,30 @@ internal fun recoveryGatewayName(
?.takeIf { it.isNotEmpty() }
?: "Home Gateway"
/** Resolves onboarding setup-code or manual fields into the gateway config used for connect. */
internal fun resolveOnboardingGatewayConnectConfig(
/** Resolves onboarding setup-code or manual fields into the gateway plan used for connect. */
internal fun resolveOnboardingGatewayConnectPlan(
setupCode: String,
savedManualHost: String,
savedManualPort: String,
savedManualTls: Boolean,
manualHost: String,
manualPort: String,
manualTls: Boolean,
token: String,
password: String,
): GatewayConnectConfig? =
resolveGatewayConnectConfig(
): GatewayConnectPlan? =
resolveGatewayConnectPlan(
useSetupCode = setupCode.isNotBlank(),
setupCode = setupCode,
savedManualHost = manualHost,
savedManualPort = manualPort,
savedManualTls = manualTls,
savedManualHost = savedManualHost,
savedManualPort = savedManualPort,
savedManualTls = savedManualTls,
manualHostInput = manualHost,
manualPortInput = manualPort,
manualTlsInput = manualTls,
fallbackBootstrapToken = "",
fallbackToken = token,
fallbackPassword = password,
bootstrapTokenInput = "",
tokenInput = token,
passwordInput = password,
)
/** Selects the recovery detail line from endpoint metadata and transient gateway status. */
@@ -1180,26 +1304,24 @@ internal fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
statusText: String,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
gatewayConnectionProblem: GatewayConnectionProblem?,
): String =
if (ready) {
remoteAddress?.takeIf { it.isNotBlank() } ?: "Ready for chat and voice"
} else if (
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingApproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.PendingReapproval ||
nodeCapabilityApprovalState == GatewayNodeApprovalState.Unapproved
) {
"Gateway paired. Waiting for node capability approval."
} else if (nodeCapabilityApproval.needsApproval()) {
recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem)
?.let { "Gateway paired. Run this on the gateway host:" }
?: "Gateway paired. Waiting for node capability approval."
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem)
?.let { "Gateway approval is pending. Run this on the gateway host:" }
?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry."
} else if (gatewayConnectionProblem?.isPairingRequired == true && gatewayConnectionProblem.canAutoRetry) {
"Gateway approval is in progress. OpenClaw will retry automatically."
} else if (gatewayConnectionProblem != null) {
recoveryGatewayAuthDetail(gatewayConnectionProblem)
} else if (nodeCapabilityApprovalState == GatewayNodeApprovalState.Loading) {
} else if (nodeCapabilityApproval == GatewayNodeCapabilityApproval.Loading) {
"Gateway paired. Checking node capability approval."
} else if (statusText.contains("operator offline", ignoreCase = true)) {
"Gateway paired. Waiting for operator access."
@@ -1216,6 +1338,10 @@ internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnecti
"AUTH_DEVICE_TOKEN_MISMATCH",
"AUTH_TOKEN_MISMATCH",
-> "Saved authentication is invalid. Re-authenticate or reset this gateway connection."
"AUTH_TOKEN_NOT_CONFIGURED",
"AUTH_PASSWORD_NOT_CONFIGURED",
-> "Gateway authentication is not configured. Configure it on the gateway host, then retry."
"AUTH_SCOPE_MISMATCH" -> "Gateway access needs review. Check gateway authentication scopes, then retry."
"AUTH_PASSWORD_MISSING" -> "Gateway password is required. Enter it again or edit this connection."
"AUTH_PASSWORD_MISMATCH" -> "Gateway password is invalid. Re-enter it or reset this gateway connection."
"AUTH_TOKEN_MISSING" -> "Gateway token is required. Enter it again or edit this connection."
@@ -1225,7 +1351,7 @@ internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnecti
else ->
when (gatewayConnectionProblem.recommendedNextStep) {
"update_auth_credentials" -> "Saved authentication is invalid. Re-authenticate or reset this gateway connection."
"update_auth_configuration" -> "Gateway authentication is not configured. Edit this connection and try again."
"update_auth_configuration" -> "Gateway authentication is not configured. Configure it on the gateway host, then retry."
"review_auth_configuration" -> "Gateway authentication needs review. Check gateway settings, then retry."
else -> gatewayConnectionProblem.message.takeIf { it.isNotBlank() } ?: "Gateway authentication needs attention."
}
@@ -1265,9 +1391,18 @@ private fun protocolMismatchVersions(
?.joinToString(prefix = "(", postfix = ").")
}
private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? {
private fun GatewayNodeCapabilityApproval.needsApproval(): Boolean =
this is GatewayNodeCapabilityApproval.PendingApproval ||
this is GatewayNodeCapabilityApproval.PendingReapproval ||
this == GatewayNodeCapabilityApproval.Unapproved
internal fun recoveryGatewayApprovalCommand(
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
gatewayConnectionProblem: GatewayConnectionProblem?,
): String? {
gatewayNodeApprovalCommand(nodeCapabilityApproval)?.let { return it }
if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null
val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() }
val requestId = normalizeGatewayApprovalRequestId(gatewayConnectionProblem.requestId)
return if (requestId != null) {
"openclaw devices approve $requestId"
} else {
@@ -1291,9 +1426,10 @@ private fun copyGatewayDiagnostic(
serverName: String?,
remoteAddress: String?,
ready: Boolean,
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
gatewayConnectionProblem: GatewayConnectionProblem?,
) {
val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem)
val approvalCommand = recoveryGatewayApprovalCommand(nodeCapabilityApproval, gatewayConnectionProblem)
val diagnostic =
listOfNotNull(
"OpenClaw Android gateway diagnostic",
@@ -1329,21 +1465,24 @@ private class PermissionState(
internal fun canFinishOnboarding(
isConnected: Boolean,
isNodeConnected: Boolean,
nodeCapabilityApprovalState: GatewayNodeApprovalState,
nodeCapabilityApproval: GatewayNodeCapabilityApproval,
): Boolean =
isConnected &&
isNodeConnected &&
when (nodeCapabilityApprovalState) {
GatewayNodeApprovalState.PendingApproval,
GatewayNodeApprovalState.PendingReapproval,
GatewayNodeApprovalState.Unapproved,
GatewayNodeApprovalState.Loading,
when (nodeCapabilityApproval) {
is GatewayNodeCapabilityApproval.PendingApproval,
is GatewayNodeCapabilityApproval.PendingReapproval,
GatewayNodeCapabilityApproval.Unapproved,
GatewayNodeCapabilityApproval.Loading,
-> false
GatewayNodeApprovalState.Approved,
GatewayNodeApprovalState.Unsupported,
GatewayNodeCapabilityApproval.Approved,
GatewayNodeCapabilityApproval.Unsupported,
-> true
}
private val requiredContactPermissions = listOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)
private val requiredCalendarPermissions = listOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
/** Builds permission rows and applies granted feature toggles after onboarding. */
@Composable
private fun rememberPermissionState(
@@ -1355,10 +1494,14 @@ private fun rememberPermissionState(
var locationGranted by rememberSaveable {
mutableStateOf(hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(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
var photosGranted by rememberSaveable { mutableStateOf(hasPermission(context, photosPermission)) }
var contactsGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.READ_CONTACTS)) }
var calendarGranted by rememberSaveable { mutableStateOf(hasPermission(context, Manifest.permission.READ_CALENDAR)) }
val photosPermissions = photoReadPermissionsForRequest()
var photosGranted by rememberSaveable { mutableStateOf(hasPhotoReadPermission(context)) }
var contactsGranted by rememberSaveable {
mutableStateOf(requiredContactPermissions.all { permission -> hasPermission(context, permission) })
}
var calendarGranted by rememberSaveable {
mutableStateOf(requiredCalendarPermissions.all { permission -> hasPermission(context, permission) })
}
var notificationsGranted by rememberSaveable {
mutableStateOf(Build.VERSION.SDK_INT < 33 || hasPermission(context, Manifest.permission.POST_NOTIFICATIONS))
}
@@ -1403,9 +1546,19 @@ private fun rememberPermissionState(
permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true ||
locationGranted
photosGranted = permissions[photosPermission] ?: photosGranted
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
photosGranted = hasPhotoReadPermission(context) || photosPermissions.any { permissions[it] == true }
contactsGranted =
mergedRequiredPermissionGrantState(
permissions = permissions,
requiredPermissions = requiredContactPermissions,
currentlyGranted = { permission -> hasPermission(context, permission) },
)
calendarGranted =
mergedRequiredPermissionGrantState(
permissions = permissions,
requiredPermissions = requiredCalendarPermissions,
currentlyGranted = { permission -> hasPermission(context, permission) },
)
notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
@@ -1414,8 +1567,11 @@ private fun rememberPermissionState(
}
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
smsGranted =
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&
(permissions[Manifest.permission.READ_SMS] ?: smsGranted)
mergedRequiredPermissionGrantState(
permissions = permissions,
requiredPermissions = listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
currentlyGranted = { permission -> hasPermission(context, permission) },
)
callLogGranted = permissions[Manifest.permission.READ_CALL_LOG] ?: callLogGranted
}
@@ -1436,16 +1592,16 @@ private fun rememberPermissionState(
},
if (photosAvailable) {
PermissionRowModel("Photos", "Attach photos and media", Icons.Default.Image, photosGranted) {
request(photosPermission)
request(*photosPermissions.toTypedArray())
}
} else {
null
},
PermissionRowModel("Contacts", "Read contacts securely", Icons.Default.Person, contactsGranted) {
request(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)
request(*requiredContactPermissions.toTypedArray())
},
PermissionRowModel("Calendar", "Read events and schedules", Icons.Default.CalendarMonth, calendarGranted) {
request(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
request(*requiredCalendarPermissions.toTypedArray())
},
PermissionRowModel("Notifications", "Send important alerts", Icons.Default.Notifications, notificationsGranted) {
if (Build.VERSION.SDK_INT >= 33) request(Manifest.permission.POST_NOTIFICATIONS)
@@ -1486,6 +1642,13 @@ private fun rememberPermissionState(
)
}
/** RequestMultiplePermissions only reports launched permissions, so omitted entries use current system state. */
internal fun mergedRequiredPermissionGrantState(
permissions: Map<String, Boolean>,
requiredPermissions: List<String>,
currentlyGranted: (String) -> Boolean,
): Boolean = requiredPermissions.all { permission -> permissions[permission] ?: currentlyGranted(permission) }
private fun hasPermission(
context: Context,
permission: String,
@@ -104,14 +104,13 @@ fun PostOnboardingTabs(
}
}
val statusText by viewModel.statusText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val statusVisual =
remember(statusText, isConnected) {
val lower = statusText.lowercase()
remember(gatewayConnectionDisplay) {
val lower = gatewayConnectionDisplay.statusText.lowercase()
when {
isConnected -> StatusVisual.Connected
gatewayConnectionDisplay.isConnected -> StatusVisual.Connected
lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting
lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning
lower.contains("error") || lower.contains("failed") -> StatusVisual.Error
@@ -129,7 +128,7 @@ fun PostOnboardingTabs(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
TopStatusBar(
statusText = statusText,
statusText = gatewayConnectionDisplay.statusText,
statusVisual = statusVisual,
)
},
@@ -1,18 +1,31 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AndroidLicenseNotice
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayTalkSetupReadiness
import ai.openclaw.app.GatewayTalkSetupState
import ai.openclaw.app.GatewayUsageProviderSummary
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.gatewayTalkSetupDescription
import ai.openclaw.app.gatewayTalkSetupStatusText
import ai.openclaw.app.hasPhotoReadPermission
import ai.openclaw.app.isReady
import ai.openclaw.app.loadAndroidLicenseNotices
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.photoReadPermissionsForRequest
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListItem
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
@@ -32,10 +45,12 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioManager
import android.media.ToneGenerator
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
@@ -68,6 +83,7 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
@@ -78,12 +94,15 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -94,10 +113,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Detail routes reachable from the Android settings home surface.
@@ -122,6 +146,7 @@ internal enum class SettingsRoute {
Appearance,
Health,
About,
Licenses,
}
/**
@@ -153,6 +178,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Appearance -> AppearanceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Licenses -> LicensesSettingsScreen(onBack = onBack)
}
}
@@ -386,14 +412,16 @@ private fun VoiceSettingsScreen(
onBack: () -> Unit,
) {
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val talkSetupReadiness by viewModel.talkSetupReadiness.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) viewModel.refreshTalkSetupReadiness()
}
SettingsDetailFrame(title = "Talk Provider Setup", subtitle = "Configure voice, transport, and playback.", icon = Icons.Default.Mic, onBack = onBack) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
VoiceSetupPanel(
voiceActive = micEnabled || talkModeEnabled,
)
VoiceSetupPanel(talkSetupReadiness)
Text(text = "Audio Test", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Check that OpenClaw can speak clearly on this phone.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
SettingsWaveformPanel(active = speakerEnabled, onClick = ::playVoiceSetupTone)
@@ -412,33 +440,29 @@ private fun VoiceSettingsScreen(
@Composable
private fun VoiceSetupPanel(
voiceActive: Boolean,
readiness: GatewayTalkSetupReadiness,
) {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
VoiceSetupActionRow(
title = "Realtime Provider",
subtitle = "Gateway voice relay",
icon = Icons.Default.GraphicEq,
statusText = if (voiceActive) "Live" else "Ready",
ready = true,
)
VoiceSetupActionRow(
title = "Voice",
subtitle = "Voice input",
icon = Icons.Default.Mic,
statusText = "Configured",
ready = true,
)
VoiceSetupActionRow(
title = "Transport",
subtitle = "Socket relay",
icon = Icons.Default.Bolt,
statusText = "Configured",
ready = true,
)
VoiceSetupReadinessRow(title = "Realtime Talk", state = readiness.realtimeTalk, icon = Icons.Default.GraphicEq)
VoiceSetupReadinessRow(title = "Dictation", state = readiness.dictation, icon = Icons.Default.Mic)
}
}
@Composable
private fun VoiceSetupReadinessRow(
title: String,
state: GatewayTalkSetupState,
icon: ImageVector,
) {
VoiceSetupActionRow(
title = title,
subtitle = gatewayTalkSetupDescription(state),
icon = icon,
statusText = gatewayTalkSetupStatusText(state),
ready = state.isReady,
)
}
@Composable
private fun VoiceSetupActionRow(
title: String,
@@ -749,12 +773,16 @@ private fun PhoneCapabilitiesScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
val preventSleep by viewModel.preventSleep.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
val photosAvailable = remember { SensitiveFeatureConfig.photosEnabled }
val photoPermissions = remember { photoReadPermissionsForRequest() }
var photosGranted by remember { mutableStateOf(photosAvailable && hasPhotoReadPermission(context)) }
val cameraPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
viewModel.setCameraEnabled(granted)
@@ -765,6 +793,21 @@ private fun PhoneCapabilitiesScreen(
viewModel.setLocationMode(if (granted) LocationMode.WhileUsing else LocationMode.Off)
viewModel.setLocationPreciseEnabled(grants[Manifest.permission.ACCESS_FINE_LOCATION] == true)
}
val photoPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
photosGranted = photosAvailable && hasPhotoReadPermission(context)
}
DisposableEffect(lifecycleOwner, context, photosAvailable) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
photosGranted = photosAvailable && hasPhotoReadPermission(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
fun setCameraAccess(checked: Boolean) {
if (!checked) {
@@ -803,12 +846,31 @@ private fun PhoneCapabilitiesScreen(
}
}
fun setPhotoAccess(checked: Boolean) {
if (checked && !hasPhotoReadPermission(context)) {
photoPermissionLauncher.launch(photoPermissions.toTypedArray())
} else {
openAppPermissionSettings(context)
}
}
SettingsDetailFrame(title = "Phone Capabilities", subtitle = "Choose what this phone can share.", icon = Icons.AutoMirrored.Filled.ScreenShare, onBack = onBack) {
SettingsTogglePanel(
rows =
listOf(
listOfNotNull(
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
if (photosAvailable) {
SettingsToggleRow(
"Photos",
if (photosGranted) "Selected or full photo access granted." else "Allow photo library access.",
Icons.Default.Image,
photosGranted,
::setPhotoAccess,
)
} else {
null
},
SettingsToggleRow(
"Installed Apps",
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
@@ -838,16 +900,13 @@ private fun GatewaySettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val savedBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val savedGatewayToken by viewModel.gatewayToken.collectAsState()
var setupCode by remember { mutableStateOf("") }
var hostInput by remember(manualHost) { mutableStateOf(manualHost.ifBlank { "127.0.0.1" }) }
var portInput by remember(manualPort) { mutableStateOf(manualPort.toString()) }
@@ -857,16 +916,55 @@ private fun GatewaySettingsScreen(
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
var pendingSetupResetPlan by remember { mutableStateOf<GatewayConnectPlan?>(null) }
fun saveAndConnect(plan: GatewayConnectPlan) {
validationText = null
viewModel.saveGatewayConfigAndConnect(plan)
}
pendingSetupResetPlan?.let { plan ->
AlertDialog(
onDismissRequest = { pendingSetupResetPlan = null },
title = { Text("Replace gateway setup?") },
text = {
Text(
gatewaySettingsSetupResetConfirmationText(),
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
},
confirmButton = {
TextButton(
onClick = {
pendingSetupResetPlan = null
saveAndConnect(plan)
},
) {
Text("Replace setup")
}
},
dismissButton = {
TextButton(onClick = { pendingSetupResetPlan = null }) {
Text("Cancel")
}
},
containerColor = ClawTheme.colors.surface,
)
}
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Connection", if (isConnected) "Connected" else "Offline"),
SettingsMetric("Connection", if (gatewayConnectionDisplay.isConnected) "Connected" else "Offline"),
SettingsMetric("Node", if (isNodeConnected) "Online" else "Not paired"),
SettingsMetric("Gateway", serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"),
SettingsMetric("Address", remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"),
SettingsMetric("Status", gatewayStatusLabel(statusText = statusText, isConnected = isConnected)),
SettingsMetric(
"Status",
gatewayStatusLabel(gatewayConnectionDisplay),
),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -914,45 +1012,29 @@ private fun GatewaySettingsScreen(
ClawPrimaryButton(
text = "Save & Connect",
onClick = {
val setup = setupCode.trim().takeIf { it.isNotEmpty() }?.let(::decodeGatewaySetupCode)
val endpointConfig =
if (setup != null) {
parseGatewayEndpointResult(setup.url).config
} else {
composeGatewayManualUrl(hostInput, portInput, tlsInput)?.let { parseGatewayEndpointResult(it).config }
}
if (endpointConfig == null) {
val plan =
resolveGatewayConnectPlan(
useSetupCode = setupCode.isNotBlank(),
setupCode = setupCode,
savedManualHost = manualHost,
savedManualPort = manualPort.toString(),
savedManualTls = manualTls,
manualHostInput = hostInput,
manualPortInput = portInput,
manualTlsInput = tlsInput,
tokenInput = tokenInput,
bootstrapTokenInput = bootstrapTokenInput,
passwordInput = passwordInput,
)
if (plan == null) {
validationText = "Enter a valid setup code or gateway address."
return@ClawPrimaryButton
}
val bootstrapToken =
setup
?.bootstrapToken
?.trim()
.orEmpty()
.ifEmpty { bootstrapTokenInput.trim().ifEmpty { savedBootstrapToken } }
val token =
setup
?.token
?.trim()
.orEmpty()
.ifEmpty { tokenInput.trim().ifEmpty { if (bootstrapToken.isBlank()) savedGatewayToken else "" } }
val password =
setup
?.password
?.trim()
.orEmpty()
.ifEmpty { passwordInput.trim() }
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = endpointConfig.host,
port = endpointConfig.port,
tls = endpointConfig.tls,
token = token,
bootstrapToken = bootstrapToken,
password = password,
resetSetupAuth = setup != null,
)
if (plan.savedAuthAction == GatewaySavedAuthAction.REPLACE_SETUP) {
pendingSetupResetPlan = plan
} else {
saveAndConnect(plan)
}
},
modifier = Modifier.fillMaxWidth(),
)
@@ -997,16 +1079,17 @@ internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entrie
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
/** Converts raw gateway connection text into stable settings metric labels. */
private fun gatewayStatusLabel(
internal fun gatewayStatusLabel(
statusText: String,
isConnected: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): String {
if (isConnected) return "Ready"
val status = statusText.trim().lowercase()
return when {
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
status.contains("pair") -> "Pairing needed"
status.contains("auth") -> "Authentication needed"
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: "Authentication needed"
status.contains("fingerprint verification timed out") -> "TLS timed out"
status.contains("no tls endpoint") -> "No TLS endpoint"
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
@@ -1016,6 +1099,8 @@ private fun gatewayStatusLabel(
}
}
internal fun gatewayStatusLabel(display: GatewayConnectionDisplay): String = gatewayStatusLabel(display.statusText, display.isConnected, display.problem)
@Composable
private fun AboutSettingsScreen(
viewModel: MainViewModel,
@@ -1057,6 +1142,68 @@ private fun AboutSettingsScreen(
}
}
@Composable
private fun LicensesSettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val licenses = remember(context) { loadAndroidLicenseNotices(context.assets) }
var selectedLicense by remember { mutableStateOf<AndroidLicenseNotice?>(null) }
val backToListOrSettings = {
if (selectedLicense == null) {
onBack()
} else {
selectedLicense = null
}
}
BackHandler(enabled = selectedLicense != null) {
selectedLicense = null
}
SettingsDetailFrame(
title = "Licenses",
subtitle = if (selectedLicense == null) "OpenClaw appreciates its partners in the open-source community." else "",
subtitleTextAlign = TextAlign.Center,
icon = Icons.Default.Info,
onBack = backToListOrSettings,
) {
val selected = selectedLicense
if (selected == null) {
if (licenses.isEmpty()) {
ClawPanel {
Text(text = "No license notices are packaged in this build.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
} else {
ClawListPanel(items = licenses) { license ->
LicenseListRow(license = license, onClick = { selectedLicense = license })
}
}
} else {
ClawPanel {
Text(text = selected.text, style = ClawTheme.type.caption.copy(fontFamily = FontFamily.Monospace), color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun LicenseListRow(
license: AndroidLicenseNotice,
onClick: () -> Unit,
) {
ClawListItem(
title = license.title,
onClick = onClick,
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open ${license.title}",
modifier = Modifier.size(20.dp),
tint = ClawTheme.colors.text,
)
},
)
}
internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): String =
when (flavor.trim()) {
"play" -> "Play"
@@ -1065,6 +1212,10 @@ internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): St
else -> flavor.trim()
}
internal fun gatewaySettingsSetupResetConfirmationText(): String =
"Replacing the setup code clears this phone's saved setup credentials and device tokens before reconnecting. " +
"This phone may need node capability approval again; continue only when you mean to pair with a fresh gateway setup code."
@Composable
private fun AboutStatusRow(
title: String,
@@ -1101,6 +1252,7 @@ internal fun SettingsDetailFrame(
subtitle: String,
icon: ImageVector,
onBack: () -> Unit,
subtitleTextAlign: TextAlign = TextAlign.Start,
content: @Composable () -> Unit,
) {
ClawScaffold(
@@ -1119,8 +1271,16 @@ internal fun SettingsDetailFrame(
SettingsIconMark(icon = icon)
}
}
item {
Text(text = subtitle, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
if (subtitle.isNotBlank()) {
item {
Text(
text = subtitle,
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
modifier = Modifier.fillMaxWidth(),
textAlign = subtitleTextAlign,
)
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -1566,3 +1726,12 @@ private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
private fun openAppPermissionSettings(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)
}
@@ -0,0 +1,90 @@
package ai.openclaw.app.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.setValue
/**
* Shell navigation state: the visible tab, the open settings route, and where Back
* returns after a cross-tab detail open. All tab/route transitions go through this
* class so Back semantics stay consistent across every entry point.
*/
internal class ShellNavigation(
activeTab: Tab = Tab.Overview,
settingsRoute: SettingsRoute = SettingsRoute.Home,
returnTab: Tab? = null,
settingsRouteFromHome: Boolean = false,
) {
var activeTab by mutableStateOf(activeTab)
private set
var settingsRoute by mutableStateOf(settingsRoute)
private set
// Single-slot origin: Back from a cross-tab detail (settings route, Sessions,
// Providers) returns to the tab that opened it; deeper history intentionally
// collapses to Overview so the shell never accumulates a navigation stack.
private var returnTab by mutableStateOf(returnTab)
// Distinguishes a detail reached from the Settings Home list (Back unwinds to
// Home) from one opened cross-tab (Back leaves the Settings tab entirely).
private var settingsRouteFromHome by mutableStateOf(settingsRouteFromHome)
/** Tab-bar-style switch: Back from the selected tab returns to Overview. */
fun selectTab(tab: Tab) {
if (tab == Tab.Settings) settingsRoute = SettingsRoute.Home
settingsRouteFromHome = false
returnTab = null
activeTab = tab
}
/** Opens a settings route from another tab, remembering the origin for Back. */
fun openSettingsRoute(route: SettingsRoute) {
settingsRoute = route
settingsRouteFromHome = false
openDetailTab(Tab.Settings)
}
/** Opens a settings route from the Settings Home list; Back returns to Home. */
fun openSettingsRouteFromHome(route: SettingsRoute) {
settingsRoute = route
settingsRouteFromHome = true
}
/** Opens a detail tab (Sessions, Providers) from another tab, remembering the origin for Back. */
fun openDetailTab(tab: Tab) {
if (activeTab != tab) returnTab = activeTab
activeTab = tab
}
/** Unwinds one Back step: settings detail to Home or origin, otherwise tab to origin or Overview. */
fun back() {
if (activeTab == Tab.Settings && settingsRoute != SettingsRoute.Home) {
settingsRoute = SettingsRoute.Home
if (settingsRouteFromHome) {
settingsRouteFromHome = false
return
}
}
activeTab = returnTab ?: Tab.Overview
returnTab = null
}
companion object {
/** Persists shell navigation across process death for rememberSaveable. */
val Saver =
listSaver<ShellNavigation, String>(
save = { nav ->
listOf(nav.activeTab.name, nav.settingsRoute.name, nav.returnTab?.name.orEmpty(), nav.settingsRouteFromHome.toString())
},
restore = { saved ->
ShellNavigation(
activeTab = Tab.valueOf(saved[0]),
settingsRoute = SettingsRoute.valueOf(saved[1]),
returnTab = saved[2].takeIf { it.isNotEmpty() }?.let(Tab::valueOf),
settingsRouteFromHome = saved[3].toBoolean(),
)
},
)
}
}
@@ -3,6 +3,8 @@ package ai.openclaw.app.ui
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayConnectionDisplay
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodesDevicesSummary
@@ -96,6 +98,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -134,9 +137,7 @@ fun ShellScreen(
val shellDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
OpenClawSystemBarAppearance(lightAppearance = !shellDark)
ClawDesignTheme(dark = shellDark) {
var activeTab by rememberSaveable { mutableStateOf(Tab.Overview) }
var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) }
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
val nav = rememberSaveable(saver = ShellNavigation.Saver) { ShellNavigation() }
var commandOpen by rememberSaveable { mutableStateOf(false) }
var voiceScreenWasActive by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
@@ -147,31 +148,28 @@ fun ShellScreen(
val destination = requestedHomeDestination ?: return@LaunchedEffect
// HomeDestination is a one-shot command from launch intents and settings
// actions; consume it after translating to local shell state.
activeTab =
nav.selectTab(
when (destination) {
HomeDestination.Connect -> Tab.Overview
HomeDestination.Chat -> Tab.Chat
HomeDestination.Voice -> Tab.Voice
HomeDestination.Screen -> Tab.Chat
HomeDestination.Settings -> Tab.Settings
}
if (destination == HomeDestination.Settings) {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
}
},
)
viewModel.clearRequestedHomeDestination()
}
LaunchedEffect(activeTab, runtimeInitialized) {
val voiceScreenActive = activeTab == Tab.Voice
LaunchedEffect(nav.activeTab, runtimeInitialized) {
val voiceScreenActive = nav.activeTab == Tab.Voice
if (voiceScreenActive || voiceScreenWasActive || runtimeInitialized) {
viewModel.setVoiceScreenActive(voiceScreenActive)
}
voiceScreenWasActive = voiceScreenActive
}
BackHandler(enabled = activeTab != Tab.Overview) {
activeTab = Tab.Overview
BackHandler(enabled = nav.activeTab != Tab.Overview) {
nav.back()
}
BackHandler(enabled = commandOpen) {
@@ -190,80 +188,54 @@ fun ShellScreen(
if (showBottomNav) {
ClawBottomNav(
items = shellNavTabs.map { ClawNavItem(key = it.key, label = it.label, icon = it.icon) },
selectedKey = if (activeTab in shellNavTabs) activeTab.key else Tab.Overview.key,
selectedKey = if (nav.activeTab in shellNavTabs) nav.activeTab.key else Tab.Overview.key,
onSelect = { key ->
val next = shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview
if (next == Tab.Settings) {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
}
activeTab = next
nav.selectTab(shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview)
},
)
}
},
) { shellPadding ->
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
when (activeTab) {
when (nav.activeTab) {
Tab.Overview ->
OverviewScreen(
viewModel = viewModel,
onSelectTab = { activeTab = it },
onOpenSettingsRoute = {
settingsRoute = it
returnToOverviewFromSettings = true
activeTab = Tab.Settings
},
onSelectTab = nav::selectTab,
onOpenSettingsRoute = nav::openSettingsRoute,
onOpenCommand = { commandOpen = true },
)
Tab.Chat ->
ChatShellScreen(
viewModel = viewModel,
onVoice = { activeTab = Tab.Voice },
onOpenSessions = { activeTab = Tab.Sessions },
onVoice = { nav.selectTab(Tab.Voice) },
onOpenSessions = { nav.openDetailTab(Tab.Sessions) },
onOpenGatewaySettings = { nav.openSettingsRoute(SettingsRoute.Gateway) },
)
Tab.Voice ->
VoiceShellScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenGatewaySettings = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
onOpenVoiceSettings = {
settingsRoute = SettingsRoute.Voice
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
onOpenGatewaySettings = { nav.openSettingsRoute(SettingsRoute.Gateway) },
onOpenVoiceSettings = { nav.openSettingsRoute(SettingsRoute.Voice) },
)
Tab.ProvidersModels ->
ProvidersModelsScreen(
viewModel = viewModel,
onBack = { activeTab = Tab.Overview },
onBack = nav::back,
)
Tab.Sessions ->
SessionsScreen(
viewModel = viewModel,
onOpenCommand = { commandOpen = true },
onOpenChat = { activeTab = Tab.Chat },
onOpenChat = { nav.selectTab(Tab.Chat) },
)
Tab.Settings ->
SettingsShellScreen(
viewModel = viewModel,
route = settingsRoute,
onRouteChange = {
settingsRoute = it
returnToOverviewFromSettings = false
},
onRouteBack = {
settingsRoute = SettingsRoute.Home
if (returnToOverviewFromSettings) {
returnToOverviewFromSettings = false
activeTab = Tab.Overview
}
},
onBackHome = { activeTab = Tab.Overview },
route = nav.settingsRoute,
onRouteChange = nav::openSettingsRouteFromHome,
onBack = nav::back,
onOpenCommand = { commandOpen = true },
)
}
@@ -273,30 +245,28 @@ fun ShellScreen(
viewModel = viewModel,
onDismiss = { commandOpen = false },
onOpenChat = {
activeTab = Tab.Chat
nav.selectTab(Tab.Chat)
commandOpen = false
},
onOpenVoice = {
activeTab = Tab.Voice
nav.selectTab(Tab.Voice)
commandOpen = false
},
onOpenSessions = {
activeTab = Tab.Sessions
nav.openDetailTab(Tab.Sessions)
commandOpen = false
},
onOpenProviders = {
activeTab = Tab.ProvidersModels
nav.openDetailTab(Tab.ProvidersModels)
commandOpen = false
},
onOpenSettings = {
settingsRoute = SettingsRoute.Home
returnToOverviewFromSettings = false
activeTab = Tab.Settings
nav.openSettingsRoute(SettingsRoute.Home)
commandOpen = false
},
onOpenSession = { sessionKey ->
viewModel.switchChatSession(sessionKey)
activeTab = Tab.Chat
nav.selectTab(Tab.Chat)
commandOpen = false
},
)
@@ -325,24 +295,34 @@ private fun GatewayTrustDialog(
) {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"Verify the certificate fingerprint before trusting this gateway.\n\n${prompt.fingerprintSha256}"
stringResource(R.string.gateway_trust_first_seen, prompt.fingerprintSha256)
} else {
"The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256:\n${prompt.fingerprintSha256}"
stringResource(
R.string.gateway_trust_changed,
prompt.previousFingerprintSha256,
prompt.fingerprintSha256,
)
}
AlertDialog(
onDismissRequest = onDecline,
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
title = {
Text(
stringResource(R.string.trust_this_gateway),
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
},
text = { Text(message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) },
confirmButton = {
TextButton(onClick = onAccept) {
Text("Trust")
Text(stringResource(R.string.trust_and_continue))
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text("Cancel")
Text(stringResource(R.string.cancel))
}
},
)
@@ -355,10 +335,10 @@ private fun OverviewScreen(
onOpenSettingsRoute: (SettingsRoute) -> Unit,
onOpenCommand: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val isConnected = gatewayConnectionDisplay.isConnected
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val execApprovals by viewModel.execApprovals.collectAsState()
@@ -431,8 +411,8 @@ private fun OverviewScreen(
OverviewPrimaryPanel(
agentName = activeAgentName,
agentBadge = activeAgentBadge,
statusText = gatewaySummary(statusText, isConnected),
isConnected = isConnected,
statusText = gatewaySummary(gatewayConnectionDisplay),
isConnected = gatewayConnectionDisplay.isConnected,
pendingRunCount = pendingRunCount,
sessionCount = sessions.size,
cronJobCount = cronStatus.jobs,
@@ -1317,6 +1297,7 @@ private fun ChatShellScreen(
viewModel: MainViewModel,
onVoice: () -> Unit,
onOpenSessions: () -> Unit,
onOpenGatewaySettings: () -> Unit,
) {
ClawScaffold(
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
@@ -1326,6 +1307,7 @@ private fun ChatShellScreen(
viewModel = viewModel,
onVoice = onVoice,
onOpenSessions = onOpenSessions,
onOpenGatewaySettings = onOpenGatewaySettings,
)
}
}
@@ -1355,13 +1337,12 @@ private fun SettingsShellScreen(
viewModel: MainViewModel,
route: SettingsRoute,
onRouteChange: (SettingsRoute) -> Unit,
onRouteBack: () -> Unit,
onBackHome: () -> Unit,
onBack: () -> Unit,
onOpenCommand: () -> Unit,
) {
val displayName by viewModel.displayName.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val isConnected = gatewayConnectionDisplay.isConnected
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
@@ -1394,12 +1375,11 @@ private fun SettingsShellScreen(
}
}
BackHandler(enabled = route != SettingsRoute.Home) {
onRouteBack()
}
// System Back for settings routes is owned by the shell-level BackHandler, which
// unwinds cross-tab opens to their originating tab via ShellNavigation. A local
// BackHandler here would shadow it and strand cross-tab opens on Settings Home.
if (route != SettingsRoute.Home) {
SettingsDetailScreen(viewModel = viewModel, route = route, onBack = onRouteBack)
SettingsDetailScreen(viewModel = viewModel, route = route, onBack = onBack)
return
}
@@ -1416,8 +1396,8 @@ private fun SettingsShellScreen(
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back to home",
onClick = onBackHome,
contentDescription = "Back",
onClick = onBack,
)
Text(text = "Settings", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(
@@ -1434,7 +1414,13 @@ private fun SettingsShellScreen(
val settingsRows =
listOf(
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow(
"Gateway",
gatewaySummary(gatewayConnectionDisplay),
Icons.Default.Cloud,
status = gatewayConnectionDisplay.isConnected,
route = SettingsRoute.Gateway,
),
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
@@ -1479,6 +1465,16 @@ private fun SettingsShellScreen(
)
}
item {
SettingsSectionTitle("Licenses")
}
item {
SettingsGroup(
rows = listOf(SettingsRow("Licenses", "", Icons.Default.Storage, route = SettingsRoute.Licenses)),
onOpen = onRouteChange,
)
}
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
@@ -1657,6 +1653,7 @@ internal fun settingsSectionTitleForRoute(route: SettingsRoute): String =
SettingsRoute.Profile,
SettingsRoute.Appearance,
SettingsRoute.About,
SettingsRoute.Licenses,
-> "Profile & device"
SettingsRoute.Health -> "Diagnostics"
@@ -1763,7 +1760,9 @@ private fun SettingsListRow(
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = row.value, style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (row.value.isNotBlank()) {
Text(text = row.value, style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
row.status?.let { active ->
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (active) ClawTheme.colors.success else ClawTheme.colors.textSubtle))
}
@@ -1791,19 +1790,22 @@ private fun relativeSessionTime(updatedAtMs: Long): String {
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
private fun gatewaySummary(
internal fun gatewaySummary(
statusText: String,
isConnected: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): String {
if (isConnected) return "Online and ready"
val status = statusText.trim().lowercase()
return when {
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
status.contains("pairing") -> "Waiting for pairing"
status.contains("auth") -> "Authentication needed"
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: "Authentication needed"
status.contains("fingerprint verification timed out") -> "TLS timed out"
status.contains("no tls endpoint") -> "No TLS endpoint"
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
else -> "Not connected"
}
}
internal fun gatewaySummary(display: GatewayConnectionDisplay): String = gatewaySummary(display.statusText, display.isConnected, display.problem)
@@ -1,8 +1,12 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayTalkSetupReadiness
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.gatewayTalkSetupDescription
import ai.openclaw.app.isReady
import ai.openclaw.app.requiresSetup
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
@@ -102,6 +106,7 @@ fun VoiceScreen(
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeStatusText by viewModel.talkModeStatusText.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
val talkSetupReadiness by viewModel.talkSetupReadiness.collectAsState()
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
@@ -109,9 +114,20 @@ fun VoiceScreen(
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPermission = granted
if (granted) {
// Gateway readiness can change while the system permission dialog is open.
when (pendingAction) {
VoiceAction.Talk -> viewModel.setTalkModeEnabled(true)
VoiceAction.Dictation -> viewModel.setMicEnabled(true)
VoiceAction.Talk ->
if (talkSetupReadiness.realtimeTalk.requiresSetup) {
onOpenVoiceSettings()
} else {
viewModel.setTalkModeEnabled(true)
}
VoiceAction.Dictation ->
if (talkSetupReadiness.dictation.requiresSetup) {
onOpenVoiceSettings()
} else {
viewModel.setMicEnabled(true)
}
null -> Unit
}
}
@@ -200,6 +216,7 @@ fun VoiceScreen(
micLiveTranscript = micLiveTranscript,
gatewayReady = gatewayReady,
voiceAttentionStatus = voiceAttentionStatus,
talkSetupReadiness = talkSetupReadiness,
onStartTalk = {
runVoiceAction(
action = VoiceAction.Talk,
@@ -224,12 +241,13 @@ fun VoiceScreen(
)
},
onConnectGateway = onOpenGatewaySettings,
onOpenVoiceSettings = onOpenVoiceSettings,
)
if (!hasMicPermission) {
VoicePermissionPanel(
onRequestPermission = {
pendingAction = VoiceAction.Talk
pendingAction = null
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
},
)
@@ -599,10 +617,14 @@ private fun VoiceHero(
micLiveTranscript: String?,
gatewayReady: Boolean,
voiceAttentionStatus: String?,
talkSetupReadiness: GatewayTalkSetupReadiness,
onStartTalk: () -> Unit,
onStartDictation: () -> Unit,
onConnectGateway: () -> Unit,
onOpenVoiceSettings: () -> Unit,
) {
val talkNeedsSetup = gatewayReady && talkSetupReadiness.realtimeTalk.requiresSetup
val dictationNeedsSetup = gatewayReady && talkSetupReadiness.dictation.requiresSetup
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(9.dp)) {
VoiceOrb(
active = micEnabled || talkModeEnabled,
@@ -657,11 +679,11 @@ private fun VoiceHero(
subtitle =
when {
talkModeEnabled -> "Conversation is live"
gatewayReady -> "Natural conversation in real time"
gatewayReady -> gatewayTalkSetupDescription(talkSetupReadiness.realtimeTalk)
else -> "Connect gateway to start"
},
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.RecordVoiceOver,
onClick = onStartTalk,
onClick = if (talkNeedsSetup) onOpenVoiceSettings else onStartTalk,
enabled = gatewayReady || talkModeEnabled,
)
VoiceModeRow(
@@ -669,31 +691,42 @@ private fun VoiceHero(
subtitle =
when {
micEnabled -> "Listening for one turn"
gatewayReady -> "Convert speech to text"
gatewayReady -> gatewayTalkSetupDescription(talkSetupReadiness.dictation)
else -> "Connect gateway to start"
},
icon = if (micEnabled) Icons.Default.MicOff else Icons.Default.TextFields,
onClick = onStartDictation,
onClick = if (dictationNeedsSetup) onOpenVoiceSettings else onStartDictation,
enabled = gatewayReady || micEnabled,
)
}
VoiceProviderCard(gatewayStatus = gatewayStatus, voiceAttentionStatus = voiceAttentionStatus)
VoiceProviderCard(
gatewayStatus = gatewayStatus,
voiceAttentionStatus = voiceAttentionStatus,
talkSetupReadiness = talkSetupReadiness,
)
VoicePrimaryAction(
text =
when {
talkModeEnabled -> "End Talk"
talkNeedsSetup -> "Set Up Talk"
gatewayReady -> "Start Talk"
else -> "Connect Gateway"
},
icon =
when {
talkModeEnabled -> Icons.Default.PhoneDisabled
talkNeedsSetup -> Icons.Default.Settings
gatewayReady -> Icons.Default.Phone
else -> Icons.Default.Cloud
},
onClick = if (gatewayReady || talkModeEnabled) onStartTalk else onConnectGateway,
onClick =
when {
talkModeEnabled || (gatewayReady && !talkNeedsSetup) -> onStartTalk
talkNeedsSetup -> onOpenVoiceSettings
else -> onConnectGateway
},
)
}
}
@@ -743,8 +776,17 @@ private fun VoiceModeRow(
private fun VoiceProviderCard(
gatewayStatus: String,
voiceAttentionStatus: String?,
talkSetupReadiness: GatewayTalkSetupReadiness,
) {
val ready = voiceAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
val ready =
voiceAttentionStatus == null &&
gatewayStatus.isVoiceGatewayReady() &&
talkSetupReadiness.realtimeTalk.isReady &&
talkSetupReadiness.dictation.isReady
val needsSetup =
voiceAttentionStatus == null &&
gatewayStatus.isVoiceGatewayReady() &&
(talkSetupReadiness.realtimeTalk.requiresSetup || talkSetupReadiness.dictation.requiresSetup)
Surface(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
shape = RoundedCornerShape(ClawTheme.radii.panel),
@@ -769,13 +811,12 @@ private fun VoiceProviderCard(
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = "Voice setup", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(
text = voiceAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
text = voiceAttentionStatus ?: voiceSetupSummary(gatewayStatus, talkSetupReadiness),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
@@ -787,6 +828,7 @@ private fun VoiceProviderCard(
.background(
when {
ready -> ClawTheme.colors.success
needsSetup -> ClawTheme.colors.warning
voiceAttentionStatus != null -> ClawTheme.colors.warning
else -> ClawTheme.colors.textSubtle
},
@@ -796,7 +838,9 @@ private fun VoiceProviderCard(
text =
when {
ready -> "Ready"
needsSetup -> "Setup"
voiceAttentionStatus != null -> "Attention"
gatewayStatus.isVoiceGatewayReady() -> "Unverified"
else -> "Offline"
},
style = ClawTheme.type.caption,
@@ -808,6 +852,17 @@ private fun VoiceProviderCard(
}
}
private fun voiceSetupSummary(
gatewayStatus: String,
readiness: GatewayTalkSetupReadiness,
): String {
if (!gatewayStatus.isVoiceGatewayReady()) return gatewayStatus.voiceGatewayLabel()
return listOf(
"Talk: ${gatewayTalkSetupDescription(readiness.realtimeTalk)}",
"Dictation: ${gatewayTalkSetupDescription(readiness.dictation)}",
).joinToString(" · ")
}
@Composable
private fun VoicePrimaryAction(
text: String,
@@ -104,7 +104,7 @@ fun ChatMarkdown(
text: String,
textColor: Color,
) {
val document = remember(text) { markdownParser.parse(text) as Document }
val document = remember(text) { parseChatMarkdown(text) }
val inlineStyles =
InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
@@ -588,7 +588,7 @@ internal fun buildChatInlineMarkdown(
text: String,
linkColor: Color = Color.Blue,
): AnnotatedString {
val document = markdownParser.parse(text) as Document
val document = parseChatMarkdown(text)
val paragraph = document.firstChild as? Paragraph ?: return AnnotatedString("")
return buildInlineMarkdown(
paragraph.firstChild,
@@ -601,6 +601,8 @@ internal fun buildChatInlineMarkdown(
)
}
internal fun parseChatMarkdown(text: String): Document = markdownParser.parse(text) as Document
private fun buildPlainText(start: Node?): String {
val sb = StringBuilder()
var node = start
@@ -7,12 +7,17 @@ import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.ui.copyGatewayDiagnosticsReport
import ai.openclaw.app.ui.design.ClawListItem
import ai.openclaw.app.ui.design.ClawLoadingState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint
import ai.openclaw.app.ui.gatewayStatusForDisplay
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
@@ -43,6 +48,8 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreHoriz
import androidx.compose.material.icons.filled.Refresh
@@ -85,12 +92,14 @@ fun ChatScreen(
viewModel: MainViewModel,
onVoice: () -> Unit,
onOpenSessions: () -> Unit,
onOpenGatewaySettings: () -> Unit,
) {
val messages by viewModel.chatMessages.collectAsState()
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
val errorText by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
@@ -99,7 +108,15 @@ fun ChatScreen(
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls)
val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() }
val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: gatewayConnectionDisplay.statusText)
val gatewayOffline = !gatewayConnectionDisplay.isConnected
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
@@ -181,7 +198,10 @@ fun ChatScreen(
)
errorText?.takeIf { it.isNotBlank() }?.let { error ->
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
ChatNotice(
title = "Chat needs attention",
body = userFacingChatError(error = error, gatewayConnected = gatewayConnectionDisplay.isConnected),
)
}
ChatMessageList(
@@ -191,6 +211,7 @@ fun ChatScreen(
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
gatewayOffline = gatewayOffline,
onStarterPrompt = { prompt -> input = prompt },
modifier = Modifier.weight(1f),
)
@@ -202,11 +223,22 @@ fun ChatScreen(
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
healthOk = healthOk,
gatewayOffline = gatewayOffline,
offlineStatus = offlineStatus,
pendingRunCount = pendingRunCount,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onVoice = onVoice,
onFixConnection = onOpenGatewaySettings,
onCopyDiagnostics = {
copyGatewayDiagnosticsReport(
context = context,
screen = "chat composer",
gatewayAddress = gatewayAddress,
statusText = offlineStatus,
)
},
onAbort = viewModel::abortChat,
onSend = {
val message = input.trim()
@@ -421,6 +453,7 @@ private fun ChatMessageList(
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
healthOk: Boolean,
gatewayOffline: Boolean,
onStarterPrompt: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -475,7 +508,12 @@ private fun ChatMessageList(
if (historyLoading) {
ClawLoadingState(title = "Loading session", modifier = Modifier.align(Alignment.Center))
} else {
EmptyChatHint(healthOk = healthOk, onStarterPrompt = onStarterPrompt, modifier = Modifier.align(Alignment.Center))
EmptyChatHint(
healthOk = healthOk,
gatewayOffline = gatewayOffline,
onStarterPrompt = onStarterPrompt,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
@@ -484,6 +522,7 @@ private fun ChatMessageList(
@Composable
private fun EmptyChatHint(
healthOk: Boolean,
gatewayOffline: Boolean,
onStarterPrompt: (String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -498,8 +537,10 @@ private fun EmptyChatHint(
text =
if (healthOk) {
"Start with a prompt, or use voice."
} else if (gatewayOffline) {
"Use the recovery options below to reconnect."
} else {
"Reconnect from Settings to send messages."
"Chat is checking Gateway health."
},
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
@@ -512,6 +553,21 @@ private fun EmptyChatHint(
}
}
@Composable
private fun ChatOfflineActions(
onFixConnection: () -> Unit,
onCopyDiagnostics: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ClawPrimaryButton(text = "Fix connection", icon = Icons.Default.Cloud, onClick = onFixConnection, modifier = Modifier.weight(1f))
ClawSecondaryButton(text = "Copy diagnostics", icon = Icons.Default.ContentCopy, onClick = onCopyDiagnostics, modifier = Modifier.weight(1f))
}
}
@Composable
private fun StarterPromptList(onStarterPrompt: (String) -> Unit) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
@@ -638,15 +694,7 @@ private fun ChatText(
text: String,
textColor: Color,
) {
if (text.hasMarkdownSyntax()) {
ChatMarkdown(text = text, textColor = textColor)
} else {
Text(
text = text,
style = ClawTheme.type.body,
color = textColor,
)
}
ChatMarkdown(text = text, textColor = textColor)
}
@Composable
@@ -708,11 +756,15 @@ private fun ChatComposer(
thinkingLevel: String,
contextUsage: ChatContextUsage,
healthOk: Boolean,
gatewayOffline: Boolean,
offlineStatus: String,
pendingRunCount: Int,
onThinkingLevelChange: (String) -> Unit,
onPickImages: () -> Unit,
onRemoveAttachment: (String) -> Unit,
onVoice: () -> Unit,
onFixConnection: () -> Unit,
onCopyDiagnostics: () -> Unit,
onAbort: () -> Unit,
onSend: () -> Unit,
) {
@@ -735,6 +787,14 @@ private fun ChatComposer(
)
}
if (!healthOk && gatewayOffline) {
ChatOfflineNotice(
status = offlineStatus,
onFixConnection = onFixConnection,
onCopyDiagnostics = onCopyDiagnostics,
)
}
if (pendingRunCount > 0) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Surface(
@@ -758,6 +818,31 @@ private fun ChatComposer(
}
}
@Composable
private fun ChatOfflineNotice(
status: String,
onFixConnection: () -> Unit,
onCopyDiagnostics: () -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 9.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Gateway offline",
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.warning,
)
Text(
text = status,
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
ChatOfflineActions(onFixConnection = onFixConnection, onCopyDiagnostics = onCopyDiagnostics)
}
}
}
@Composable
private fun ChatContextMeter(
thinkingLevel: String,
@@ -982,10 +1067,14 @@ private fun SendButton(
}
}
private fun userFacingChatError(error: String): String {
internal fun userFacingChatError(
error: String,
gatewayConnected: Boolean,
): String {
val lower = error.lowercase(Locale.US)
return when {
lower.contains("not connected") -> "Gateway is offline. Open Settings to reconnect."
lower.contains("not connected") && gatewayConnected -> "Chat is still checking Gateway health."
lower.contains("not connected") -> "Gateway is offline. Fix the connection below or copy diagnostics."
lower.contains("unauthorized") || lower.contains("auth") -> "Gateway authentication needs attention."
else -> error
}
@@ -1024,9 +1113,3 @@ internal fun contextMeterThinkingLabel(value: String): String =
}
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
/** Quick markdown detector used to avoid routing plain chat text through the markdown renderer. */
private fun String.hasMarkdownSyntax(): Boolean =
any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } ||
contains("\n- ") ||
contains("\n1. ")
@@ -1,6 +1,5 @@
package ai.openclaw.app.ui.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -0,0 +1,247 @@
package ai.openclaw.app.voice
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.util.Log
/** Owns one recorder and its Bluetooth route for the full capture lifecycle. */
internal class AndroidAudioInputSession private constructor(
private val audioManager: AudioManager,
private val audioRecord: AudioRecord,
) : AutoCloseable {
companion object {
private const val tag = "AudioInput"
@SuppressLint("MissingPermission")
fun open(
context: Context,
sampleRateHz: Int,
frameBytes: Int,
): AndroidAudioInputSession {
val minBuffer =
AudioRecord.getMinBufferSize(
sampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
val audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return AndroidAudioInputSession(audioManager, audioRecord).also { session ->
try {
session.openRoute()
} catch (err: RuntimeException) {
session.close()
throw err
}
}
}
}
private val lock = Any()
private val communicationRouteOwner = bluetoothCommunicationRoute.newOwner()
private val callbackHandler = Handler(Looper.getMainLooper())
private var closed = false
private var callbackRegistered = false
private var requestedInput: AudioDeviceInfo? = null
private var requestedCommunicationDevice: AudioDeviceInfo? = null
private var selectedInput: AudioDeviceInfo? = null
private val deviceCallback =
object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
refreshRouteSafely()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
refreshRouteSafely()
}
}
internal val preferredInputType: Int?
get() = synchronized(lock) { selectedInput?.type }
internal val requestedInputType: Int?
get() = synchronized(lock) { requestedInput?.type }
fun startRecording() {
audioRecord.startRecording()
Log.d(tag, "capture started preferred=${preferredInputType ?: "default"} routed=${audioRecord.routedDevice?.type ?: "pending"}")
}
fun read(
buffer: ByteArray,
offset: Int,
size: Int,
): Int = audioRecord.read(buffer, offset, size)
private fun openRoute() {
audioManager.registerAudioDeviceCallback(deviceCallback, callbackHandler)
synchronized(lock) { callbackRegistered = true }
bluetoothCommunicationRoute.begin(communicationRouteOwner)
refreshRouteSafely()
}
private fun refreshRouteSafely() {
try {
refreshRoute()
} catch (err: RuntimeException) {
// Routing is a preference; default capture remains better than losing the voice session.
Log.w(tag, "Bluetooth route update failed: ${err.message ?: err::class.simpleName}")
}
}
private fun refreshRoute() {
synchronized(lock) {
if (closed) return
val inputs = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
val communicationDevice = selectBluetoothDevice(audioManager.availableCommunicationDevices, requestedCommunicationDevice)
val communicationSelected = bluetoothCommunicationRoute.update(audioManager, communicationRouteOwner, communicationDevice)
requestedCommunicationDevice = communicationDevice.takeIf { communicationSelected }
val input = selectBluetoothInput(inputs, requestedInput, requestedCommunicationDevice)
if (!sameDevice(requestedInput, input) || !sameDevice(selectedInput, input)) {
requestedInput = input
if (audioRecord.setPreferredDevice(input)) {
selectedInput = input
Log.d(tag, "preferred input changed type=${input?.type ?: "default"}")
} else {
selectedInput = null
Log.w(tag, "preferred input rejected type=${input?.type ?: "default"}")
}
}
}
}
override fun close() {
synchronized(lock) {
if (closed) return
closed = true
if (callbackRegistered) {
runCatching { audioManager.unregisterAudioDeviceCallback(deviceCallback) }
callbackRegistered = false
}
runCatching { audioRecord.setPreferredDevice(null) }
requestedInput = null
selectedInput = null
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
runCatching { audioRecord.stop() }
}
runCatching { audioRecord.release() }
bluetoothCommunicationRoute.close(audioManager, communicationRouteOwner)
requestedCommunicationDevice = null
}
}
}
/** Serializes Android's process-wide communication route across overlapping capture cleanup. */
private class BluetoothCommunicationRoute {
private var nextOwner = 0L
private var latestOwner = 0L
private var activeOwner: Long? = null
@Synchronized
fun newOwner(): Long = ++nextOwner
@Synchronized
fun begin(owner: Long) {
if (owner > latestOwner) latestOwner = owner
}
@Synchronized
fun update(
audioManager: AudioManager,
owner: Long,
device: AudioDeviceInfo?,
): Boolean {
if (owner < latestOwner) return false
latestOwner = owner
if (device == null) {
if (activeOwner != null) audioManager.clearCommunicationDevice()
activeOwner = null
return false
}
if (!audioManager.setCommunicationDevice(device)) {
if (activeOwner != null) audioManager.clearCommunicationDevice()
activeOwner = null
return false
}
activeOwner = owner
return true
}
@Synchronized
fun close(
audioManager: AudioManager,
owner: Long,
) {
if (activeOwner != owner || owner < latestOwner) return
audioManager.clearCommunicationDevice()
activeOwner = null
}
}
private val bluetoothCommunicationRoute = BluetoothCommunicationRoute()
private fun selectBluetoothDevice(
devices: List<AudioDeviceInfo>,
current: AudioDeviceInfo? = null,
): AudioDeviceInfo? {
current
?.takeIf { candidate ->
bluetoothPriority(candidate.type) != null && devices.any { sameDevice(it, candidate) }
}?.let { return it }
return devices
.asSequence()
.mapNotNull { device -> bluetoothPriority(device.type)?.let { priority -> priority to device } }
.minWithOrNull(compareBy<Pair<Int, AudioDeviceInfo>> { it.first }.thenBy { it.second.id })
?.second
}
private fun selectBluetoothInput(
devices: List<AudioDeviceInfo>,
current: AudioDeviceInfo?,
communicationDevice: AudioDeviceInfo?,
): AudioDeviceInfo? {
if (communicationDevice == null) return selectBluetoothDevice(devices, current)
val candidates = devices.filter { it.type == communicationDevice.type }
current?.takeIf { candidate -> candidates.any { sameDevice(it, candidate) } }?.let { return it }
val address = communicationDevice.address.trim()
if (address.isNotEmpty()) {
candidates.firstOrNull { it.address == address }?.let { return it }
}
// setCommunicationDevice chooses the matching source; only override it when unambiguous.
return candidates.singleOrNull()
}
private fun bluetoothPriority(type: Int): Int? =
when (type) {
AudioDeviceInfo.TYPE_BLE_HEADSET -> 0
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> 1
else -> null
}
private fun sameDevice(
left: AudioDeviceInfo?,
right: AudioDeviceInfo?,
): Boolean = left?.id == right?.id && left?.type == right?.type
@@ -13,7 +13,7 @@ internal object ChatEventText {
fun assistantTextFromMessage(messageEl: JsonElement?): String? {
val message = messageEl.asObjectOrNull() ?: return null
val role = message["role"].asStringOrNull()
if (role != null && role != "assistant") return null
if (role != "assistant") return null
return textFromContent(message["content"])
}
@@ -5,9 +5,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
@@ -650,35 +647,14 @@ internal class MicCaptureManager(
}
transcriptionCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
var audioInput: AndroidAudioInputSession? = null
try {
val frameBytes = transcriptionSampleRateHz * 2 * transcriptionAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
transcriptionSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(transcriptionSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
audioInput = AndroidAudioInputSession.open(context, transcriptionSampleRateHz, frameBytes)
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
audioInput.startRecording()
while (coroutineContext.isActive && _micEnabled.value && transcriptionSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
val read = audioInput.read(buffer, 0, buffer.size)
if (read <= 0) continue
_inputLevel.value = pcm16Level(buffer, read)
audioFrames.trySend(buffer.copyOf(read))
@@ -688,13 +664,7 @@ internal class MicCaptureManager(
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
audioInput?.close()
}
}
}
@@ -13,9 +13,7 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -243,7 +241,13 @@ class TalkModeManager internal constructor(
}
/** Starts a push-to-talk capture session for gateway node.invoke callers. */
suspend fun beginPushToTalk(): TalkPttStartPayload {
suspend fun beginPushToTalk(allowNewCapture: Boolean): TalkPttStartPayload {
if (!allowNewCapture) {
// A background retry may reconcile an existing capture, but must never create one.
return activePttCaptureId
?.let(::TalkPttStartPayload)
?: throw IllegalStateException("NODE_BACKGROUND_UNAVAILABLE: command requires foreground")
}
if (!isConnected()) {
_statusText.value = "Gateway not connected"
throw IllegalStateException("UNAVAILABLE: Gateway not connected")
@@ -353,7 +357,7 @@ class TalkModeManager internal constructor(
)
}
beginPushToTalk()
beginPushToTalk(allowNewCapture = true)
val completion = CompletableDeferred<TalkPttStopPayload>()
pttCompletion = completion
pttAutoStopEnabled = true
@@ -691,6 +695,13 @@ class TalkModeManager internal constructor(
disableRealtimeModeAndNotifyOwner()
}
private fun realtimeCloseStatusText(reason: String?): String =
when (reason) {
null, "completed" -> "Off"
"error" -> "Talk failed: Realtime provider closed unexpectedly."
else -> "Talk failed: Realtime provider closed: $reason"
}
@SuppressLint("MissingPermission")
private fun startRealtimeCapture(sessionId: String) {
realtimeCaptureJob?.cancel()
@@ -730,35 +741,14 @@ class TalkModeManager internal constructor(
}
realtimeCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
var audioInput: AndroidAudioInputSession? = null
try {
val frameBytes = realtimeSampleRateHz * 2 * realtimeAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
audioInput = AndroidAudioInputSession.open(context, realtimeSampleRateHz, frameBytes)
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
audioInput.startRecording()
while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
val read = audioInput.read(buffer, 0, buffer.size)
if (read <= 0) continue
if (!shouldAppendRealtimeCapturedFrame(read)) continue
audioFrames.trySend(buffer.copyOf(read))
@@ -769,13 +759,7 @@ class TalkModeManager internal constructor(
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
audioInput?.close()
}
}
}
@@ -860,11 +844,15 @@ class TalkModeManager internal constructor(
Log.w(tag, "realtime error: $message")
}
"close" -> {
Log.d(tag, "realtime close reason=${obj["reason"].asStringOrNull()}")
stopRealtimeRelay(closeSession = false)
val closeReason = obj["reason"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)
val currentStatus = _statusText.value
val closeStatus =
if (currentStatus.startsWith("Talk failed:")) currentStatus else realtimeCloseStatusText(closeReason)
Log.d(tag, "realtime close reason=$closeReason")
stopRealtimeRelay(closeSession = false, preserveStatus = true)
if (_isEnabled.value) {
_isEnabled.value = false
_statusText.value = "Off"
_statusText.value = closeStatus
onStoppedByRelay()
}
}
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">اتصال البوابة</string>
<string name="connect_gateway">توصيل البوابة</string>
<string name="disconnect">قطع الاتصال</string>
<string name="trust_this_gateway">هل تثق بهذه البوابة؟</string>
<string name="trust_and_continue">الثقة والمتابعة</string>
<string name="cancel">إلغاء</string>
<string name="endpoint">نقطة النهاية</string>
<string name="status">الحالة</string>
<string name="connected_gateway_ready">بوابتك نشطة وجاهزة.</string>
<string name="connect_gateway_get_started">اتصل ببوابتك للبدء.</string>
<string name="copy_report_for_claw">نسخ التقرير لـ Claw</string>
<string name="advanced_controls">عناصر التحكم المتقدمة</string>
<string name="connection_method">طريقة الاتصال</string>
<string name="setup_code">رمز الإعداد</string>
<string name="manual">يدوي</string>
<string name="paste_setup_code">الصق رمز الإعداد</string>
<string name="host">المضيف</string>
<string name="use_tls">استخدام TLS</string>
<string name="token_optional">الرمز المميز (اختياري)</string>
<string name="password">كلمة المرور</string>
<string name="run_onboarding_again">تشغيل الإعداد الأولي مرة أخرى</string>
<string name="resolved_endpoint">نقطة النهاية التي تم حلها</string>
<string name="gateway_setup">إعداد البوابة</string>
<string name="connect_to_gateway">الاتصال ببوابتك</string>
<string name="scan_setup_code">مسح رمز الإعداد</string>
<string name="use_gateway_qr">استخدم رمز QR الخاص ببوابتك أو رمز الإعداد</string>
<string name="nearby_gateway">بوابة قريبة</string>
<string name="enter_gateway_url">أدخل عنوان URL للبوابة</string>
<string name="connect_manual_url">الاتصال باستخدام عنوان URL يدوي</string>
<string name="permissions">الأذونات</string>
<string name="done">تم</string>
<string name="gateway_trust_first_seen">تحقق من بصمة الشهادة قبل الوثوق بهذه البوابة.\n\n%1$s</string>
<string name="gateway_trust_changed">تم تغيير شهادة البوابة. تابع فقط إذا كنت تتوقع هذا التغيير.\n\nSHA-256 القديم:\n%1$s\n\nSHA-256 الجديد:\n%2$s</string>
<string name="gateway_recovery">استرداد البوابة</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Gateway-Verbindung</string>
<string name="connect_gateway">Gateway verbinden</string>
<string name="disconnect">Trennen</string>
<string name="trust_this_gateway">Diesem Gateway vertrauen?</string>
<string name="trust_and_continue">Vertrauen und fortfahren</string>
<string name="cancel">Abbrechen</string>
<string name="endpoint">Endpunkt</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Ihr Gateway ist aktiv und bereit.</string>
<string name="connect_gateway_get_started">Verbinden Sie sich mit Ihrem Gateway, um loszulegen.</string>
<string name="copy_report_for_claw">Bericht für Claw kopieren</string>
<string name="advanced_controls">Erweiterte Steuerungen</string>
<string name="connection_method">Verbindungsmethode</string>
<string name="setup_code">Einrichtungscode</string>
<string name="manual">Manuell</string>
<string name="paste_setup_code">Einrichtungscode einfügen</string>
<string name="host">Host</string>
<string name="use_tls">TLS verwenden</string>
<string name="token_optional">Token (optional)</string>
<string name="password">Passwort</string>
<string name="run_onboarding_again">Onboarding erneut ausführen</string>
<string name="resolved_endpoint">Aufgelöster Endpunkt</string>
<string name="gateway_setup">Gateway-Einrichtung</string>
<string name="connect_to_gateway">Mit Ihrem Gateway verbinden</string>
<string name="scan_setup_code">Einrichtungscode scannen</string>
<string name="use_gateway_qr">Verwenden Sie Ihren Gateway-QR- oder Einrichtungscode</string>
<string name="nearby_gateway">Gateway in der Nähe</string>
<string name="enter_gateway_url">Gateway-URL eingeben</string>
<string name="connect_manual_url">Über eine manuelle URL verbinden</string>
<string name="permissions">Berechtigungen</string>
<string name="done">Fertig</string>
<string name="gateway_trust_first_seen">Überprüfen Sie den Zertifikatfingerabdruck, bevor Sie diesem Gateway vertrauen.\n\n%1$s</string>
<string name="gateway_trust_changed">Das Gateway-Zertifikat wurde geändert. Fahren Sie nur fort, wenn Sie diese Änderung erwartet haben.\n\nAlter SHA-256-Wert:\n%1$s\n\nNeuer SHA-256-Wert:\n%2$s</string>
<string name="gateway_recovery">Gateway-Wiederherstellung</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Conexión de Gateway</string>
<string name="connect_gateway">Conectar Gateway</string>
<string name="disconnect">Desconectar</string>
<string name="trust_this_gateway">¿Confiar en este gateway?</string>
<string name="trust_and_continue">Confiar y continuar</string>
<string name="cancel">Cancelar</string>
<string name="endpoint">Endpoint</string>
<string name="status">Estado</string>
<string name="connected_gateway_ready">Tu gateway está activo y listo.</string>
<string name="connect_gateway_get_started">Conéctate a tu gateway para empezar.</string>
<string name="copy_report_for_claw">Copiar informe para Claw</string>
<string name="advanced_controls">Controles avanzados</string>
<string name="connection_method">Método de conexión</string>
<string name="setup_code">Código de configuración</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Pegar código de configuración</string>
<string name="host">Host</string>
<string name="use_tls">Usar TLS</string>
<string name="token_optional">Token (opcional)</string>
<string name="password">Contraseña</string>
<string name="run_onboarding_again">Ejecutar la incorporación de nuevo</string>
<string name="resolved_endpoint">Endpoint resuelto</string>
<string name="gateway_setup">Configuración de Gateway</string>
<string name="connect_to_gateway">Conéctate a tu Gateway</string>
<string name="scan_setup_code">Escanear código de configuración</string>
<string name="use_gateway_qr">Usa el QR o código de configuración de tu Gateway</string>
<string name="nearby_gateway">Gateway cercano</string>
<string name="enter_gateway_url">Introduce la URL del gateway</string>
<string name="connect_manual_url">Conectar usando una URL manual</string>
<string name="permissions">Permisos</string>
<string name="done">Listo</string>
<string name="gateway_trust_first_seen">Verifica la huella digital del certificado antes de confiar en este gateway.\n\n%1$s</string>
<string name="gateway_trust_changed">El certificado del gateway cambió. Continúa solo si esperabas este cambio.\n\nSHA-256 anterior:\n%1$s\n\nSHA-256 nuevo:\n%2$s</string>
<string name="gateway_recovery">Recuperación del gateway</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">اتصال دروازه</string>
<string name="connect_gateway">اتصال به دروازه</string>
<string name="disconnect">قطع اتصال</string>
<string name="trust_this_gateway">به این دروازه اعتماد دارید؟</string>
<string name="trust_and_continue">اعتماد و ادامه</string>
<string name="cancel">لغو</string>
<string name="endpoint">نقطه پایانی</string>
<string name="status">وضعیت</string>
<string name="connected_gateway_ready">دروازه شما فعال و آماده است.</string>
<string name="connect_gateway_get_started">برای شروع، به دروازه خود متصل شوید.</string>
<string name="copy_report_for_claw">کپی گزارش برای Claw</string>
<string name="advanced_controls">کنترل‌های پیشرفته</string>
<string name="connection_method">روش اتصال</string>
<string name="setup_code">کد راه‌اندازی</string>
<string name="manual">دستی</string>
<string name="paste_setup_code">کد راه‌اندازی را جای‌گذاری کنید</string>
<string name="host">میزبان</string>
<string name="use_tls">استفاده از TLS</string>
<string name="token_optional">توکن (اختیاری)</string>
<string name="password">رمز عبور</string>
<string name="run_onboarding_again">اجرای دوباره فرایند شروع به کار</string>
<string name="resolved_endpoint">نقطه پایانی حل‌شده</string>
<string name="gateway_setup">راه‌اندازی دروازه</string>
<string name="connect_to_gateway">به دروازه خود متصل شوید</string>
<string name="scan_setup_code">اسکن کد راه‌اندازی</string>
<string name="use_gateway_qr">از QR دروازه یا کد راه‌اندازی خود استفاده کنید</string>
<string name="nearby_gateway">دروازه نزدیک</string>
<string name="enter_gateway_url">URL دروازه را وارد کنید</string>
<string name="connect_manual_url">اتصال با استفاده از URL دستی</string>
<string name="permissions">مجوزها</string>
<string name="done">انجام شد</string>
<string name="gateway_trust_first_seen">پیش از اعتماد به این دروازه، اثر انگشت گواهی را تأیید کنید.\n\n%1$s</string>
<string name="gateway_trust_changed">گواهی دروازه تغییر کرده است. فقط در صورتی ادامه دهید که انتظار این تغییر را داشتید.\n\nSHA-256 قدیمی:\n%1$s\n\nSHA-256 جدید:\n%2$s</string>
<string name="gateway_recovery">بازیابی دروازه</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Connexion à la passerelle</string>
<string name="connect_gateway">Connecter la passerelle</string>
<string name="disconnect">Déconnecter</string>
<string name="trust_this_gateway">Faire confiance à cette passerelle ?</string>
<string name="trust_and_continue">Faire confiance et continuer</string>
<string name="cancel">Annuler</string>
<string name="endpoint">Point de terminaison</string>
<string name="status">État</string>
<string name="connected_gateway_ready">Votre passerelle est active et prête.</string>
<string name="connect_gateway_get_started">Connectez-vous à votre passerelle pour commencer.</string>
<string name="copy_report_for_claw">Copier le rapport pour Claw</string>
<string name="advanced_controls">Contrôles avancés</string>
<string name="connection_method">Méthode de connexion</string>
<string name="setup_code">Code de configuration</string>
<string name="manual">Manuel</string>
<string name="paste_setup_code">Coller le code de configuration</string>
<string name="host">Hôte</string>
<string name="use_tls">Utiliser TLS</string>
<string name="token_optional">Jeton (facultatif)</string>
<string name="password">Mot de passe</string>
<string name="run_onboarding_again">Relancer lintégration</string>
<string name="resolved_endpoint">Point de terminaison résolu</string>
<string name="gateway_setup">Configuration de la passerelle</string>
<string name="connect_to_gateway">Connectez-vous à votre Gateway</string>
<string name="scan_setup_code">Scanner le code de configuration</string>
<string name="use_gateway_qr">Utilisez le QR de votre Gateway ou le code de configuration</string>
<string name="nearby_gateway">Passerelle à proximité</string>
<string name="enter_gateway_url">Saisir lURL de la passerelle</string>
<string name="connect_manual_url">Se connecter avec une URL manuelle</string>
<string name="permissions">Autorisations</string>
<string name="done">Terminé</string>
<string name="gateway_trust_first_seen">Vérifiez lempreinte du certificat avant daccorder votre confiance à cette passerelle.\n\n%1$s</string>
<string name="gateway_trust_changed">Le certificat de la passerelle a changé. Continuez uniquement si vous vous attendiez à ce changement.\n\nAncien SHA-256 :\n%1$s\n\nNouveau SHA-256 :\n%2$s</string>
<string name="gateway_recovery">Récupération de la passerelle</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">गेटवे कनेक्शन</string>
<string name="connect_gateway">गेटवे कनेक्ट करें</string>
<string name="disconnect">डिस्कनेक्ट करें</string>
<string name="trust_this_gateway">इस गेटवे पर भरोसा करें?</string>
<string name="trust_and_continue">भरोसा करें और जारी रखें</string>
<string name="cancel">रद्द करें</string>
<string name="endpoint">एंडपॉइंट</string>
<string name="status">स्थिति</string>
<string name="connected_gateway_ready">आपका गेटवे सक्रिय और तैयार है।</string>
<string name="connect_gateway_get_started">शुरू करने के लिए अपने गेटवे से कनेक्ट करें।</string>
<string name="copy_report_for_claw">Claw के लिए रिपोर्ट कॉपी करें</string>
<string name="advanced_controls">उन्नत नियंत्रण</string>
<string name="connection_method">कनेक्शन विधि</string>
<string name="setup_code">सेटअप कोड</string>
<string name="manual">मैन्युअल</string>
<string name="paste_setup_code">सेटअप कोड पेस्ट करें</string>
<string name="host">होस्ट</string>
<string name="use_tls">TLS का उपयोग करें</string>
<string name="token_optional">टोकन (वैकल्पिक)</string>
<string name="password">पासवर्ड</string>
<string name="run_onboarding_again">ऑनबोर्डिंग फिर से चलाएँ</string>
<string name="resolved_endpoint">रिज़ॉल्व किया गया एंडपॉइंट</string>
<string name="gateway_setup">गेटवे सेटअप</string>
<string name="connect_to_gateway">अपने गेटवे से कनेक्ट करें</string>
<string name="scan_setup_code">सेटअप कोड स्कैन करें</string>
<string name="use_gateway_qr">अपने गेटवे QR या सेटअप कोड का उपयोग करें</string>
<string name="nearby_gateway">नज़दीकी गेटवे</string>
<string name="enter_gateway_url">गेटवे URL दर्ज करें</string>
<string name="connect_manual_url">मैन्युअल URL का उपयोग करके कनेक्ट करें</string>
<string name="permissions">अनुमतियाँ</string>
<string name="done">हो गया</string>
<string name="gateway_trust_first_seen">इस गेटवे पर भरोसा करने से पहले प्रमाणपत्र फ़िंगरप्रिंट सत्यापित करें।\n\n%1$s</string>
<string name="gateway_trust_changed">गेटवे प्रमाणपत्र बदल गया है। केवल तभी जारी रखें जब आपको इस बदलाव की अपेक्षा थी।\n\nपुराना SHA-256:\n%1$s\n\nनया SHA-256:\n%2$s</string>
<string name="gateway_recovery">गेटवे पुनर्प्राप्ति</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Koneksi Gateway</string>
<string name="connect_gateway">Hubungkan Gateway</string>
<string name="disconnect">Putuskan koneksi</string>
<string name="trust_this_gateway">Percayai gateway ini?</string>
<string name="trust_and_continue">Percayai dan lanjutkan</string>
<string name="cancel">Batal</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Gateway Anda aktif dan siap.</string>
<string name="connect_gateway_get_started">Hubungkan ke gateway Anda untuk memulai.</string>
<string name="copy_report_for_claw">Salin Laporan untuk Claw</string>
<string name="advanced_controls">Kontrol lanjutan</string>
<string name="connection_method">Metode koneksi</string>
<string name="setup_code">Kode Penyiapan</string>
<string name="manual">Manual</string>
<string name="paste_setup_code">Tempel kode penyiapan</string>
<string name="host">Host</string>
<string name="use_tls">Gunakan TLS</string>
<string name="token_optional">Token (opsional)</string>
<string name="password">Kata sandi</string>
<string name="run_onboarding_again">Jalankan onboarding lagi</string>
<string name="resolved_endpoint">Endpoint yang diselesaikan</string>
<string name="gateway_setup">Penyiapan Gateway</string>
<string name="connect_to_gateway">Hubungkan ke Gateway Anda</string>
<string name="scan_setup_code">Pindai kode penyiapan</string>
<string name="use_gateway_qr">Gunakan QR Gateway atau kode penyiapan Anda</string>
<string name="nearby_gateway">Gateway terdekat</string>
<string name="enter_gateway_url">Masukkan URL gateway</string>
<string name="connect_manual_url">Hubungkan menggunakan URL manual</string>
<string name="permissions">Izin</string>
<string name="done">Selesai</string>
<string name="gateway_trust_first_seen">Verifikasi sidik jari sertifikat sebelum memercayai gateway ini.\n\n%1$s</string>
<string name="gateway_trust_changed">Sertifikat gateway berubah. Lanjutkan hanya jika Anda mengharapkan perubahan ini.\n\nSHA-256 lama:\n%1$s\n\nSHA-256 baru:\n%2$s</string>
<string name="gateway_recovery">Pemulihan gateway</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Connessione al gateway</string>
<string name="connect_gateway">Connetti gateway</string>
<string name="disconnect">Disconnetti</string>
<string name="trust_this_gateway">Considerare attendibile questo gateway?</string>
<string name="trust_and_continue">Considera attendibile e continua</string>
<string name="cancel">Annulla</string>
<string name="endpoint">Endpoint</string>
<string name="status">Stato</string>
<string name="connected_gateway_ready">Il tuo gateway è attivo e pronto.</string>
<string name="connect_gateway_get_started">Connettiti al tuo gateway per iniziare.</string>
<string name="copy_report_for_claw">Copia report per Claw</string>
<string name="advanced_controls">Controlli avanzati</string>
<string name="connection_method">Metodo di connessione</string>
<string name="setup_code">Codice di configurazione</string>
<string name="manual">Manuale</string>
<string name="paste_setup_code">Incolla codice di configurazione</string>
<string name="host">Host</string>
<string name="use_tls">Usa TLS</string>
<string name="token_optional">Token (opzionale)</string>
<string name="password">Password</string>
<string name="run_onboarding_again">Esegui di nuovo lonboarding</string>
<string name="resolved_endpoint">Endpoint risolto</string>
<string name="gateway_setup">Configurazione gateway</string>
<string name="connect_to_gateway">Connettiti al tuo Gateway</string>
<string name="scan_setup_code">Scansiona codice di configurazione</string>
<string name="use_gateway_qr">Usa il QR del tuo Gateway o il codice di configurazione</string>
<string name="nearby_gateway">Gateway nelle vicinanze</string>
<string name="enter_gateway_url">Inserisci URL del gateway</string>
<string name="connect_manual_url">Connetti usando un URL manuale</string>
<string name="permissions">Autorizzazioni</string>
<string name="done">Fine</string>
<string name="gateway_trust_first_seen">Verifica limpronta digitale del certificato prima di considerare attendibile questo gateway.\n\n%1$s</string>
<string name="gateway_trust_changed">Il certificato del gateway è cambiato. Continua solo se ti aspettavi questa modifica.\n\nSHA-256 precedente:\n%1$s\n\nNuovo SHA-256:\n%2$s</string>
<string name="gateway_recovery">Ripristino del gateway</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">ゲートウェイ接続</string>
<string name="connect_gateway">ゲートウェイに接続</string>
<string name="disconnect">切断</string>
<string name="trust_this_gateway">このゲートウェイを信頼しますか?</string>
<string name="trust_and_continue">信頼して続行</string>
<string name="cancel">キャンセル</string>
<string name="endpoint">エンドポイント</string>
<string name="status">ステータス</string>
<string name="connected_gateway_ready">ゲートウェイはアクティブで準備完了です。</string>
<string name="connect_gateway_get_started">開始するにはゲートウェイに接続してください。</string>
<string name="copy_report_for_claw">Claw 用レポートをコピー</string>
<string name="advanced_controls">詳細コントロール</string>
<string name="connection_method">接続方法</string>
<string name="setup_code">セットアップコード</string>
<string name="manual">手動</string>
<string name="paste_setup_code">セットアップコードを貼り付け</string>
<string name="host">ホスト</string>
<string name="use_tls">TLS を使用</string>
<string name="token_optional">トークン(任意)</string>
<string name="password">パスワード</string>
<string name="run_onboarding_again">オンボーディングを再実行</string>
<string name="resolved_endpoint">解決済みエンドポイント</string>
<string name="gateway_setup">ゲートウェイ設定</string>
<string name="connect_to_gateway">ゲートウェイに接続</string>
<string name="scan_setup_code">セットアップコードをスキャン</string>
<string name="use_gateway_qr">ゲートウェイの QR またはセットアップコードを使用</string>
<string name="nearby_gateway">近くのゲートウェイ</string>
<string name="enter_gateway_url">ゲートウェイ URL を入力</string>
<string name="connect_manual_url">手動 URL で接続</string>
<string name="permissions">権限</string>
<string name="done">完了</string>
<string name="gateway_trust_first_seen">このゲートウェイを信頼する前に、証明書のフィンガープリントを確認してください。\n\n%1$s</string>
<string name="gateway_trust_changed">ゲートウェイの証明書が変更されました。想定した変更である場合のみ続行してください。\n\n以前の SHA-256:\n%1$s\n\n新しい SHA-256:\n%2$s</string>
<string name="gateway_recovery">ゲートウェイの復旧</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">게이트웨이 연결</string>
<string name="connect_gateway">게이트웨이 연결</string>
<string name="disconnect">연결 해제</string>
<string name="trust_this_gateway">이 게이트웨이를 신뢰하시겠습니까?</string>
<string name="trust_and_continue">신뢰하고 계속</string>
<string name="cancel">취소</string>
<string name="endpoint">엔드포인트</string>
<string name="status">상태</string>
<string name="connected_gateway_ready">게이트웨이가 활성화되어 준비되었습니다.</string>
<string name="connect_gateway_get_started">시작하려면 게이트웨이에 연결하세요.</string>
<string name="copy_report_for_claw">Claw용 보고서 복사</string>
<string name="advanced_controls">고급 제어</string>
<string name="connection_method">연결 방법</string>
<string name="setup_code">설정 코드</string>
<string name="manual">수동</string>
<string name="paste_setup_code">설정 코드 붙여넣기</string>
<string name="host">호스트</string>
<string name="use_tls">TLS 사용</string>
<string name="token_optional">토큰(선택 사항)</string>
<string name="password">비밀번호</string>
<string name="run_onboarding_again">온보딩 다시 실행</string>
<string name="resolved_endpoint">확인된 엔드포인트</string>
<string name="gateway_setup">게이트웨이 설정</string>
<string name="connect_to_gateway">게이트웨이에 연결</string>
<string name="scan_setup_code">설정 코드 스캔</string>
<string name="use_gateway_qr">게이트웨이 QR 또는 설정 코드 사용</string>
<string name="nearby_gateway">주변 게이트웨이</string>
<string name="enter_gateway_url">게이트웨이 URL 입력</string>
<string name="connect_manual_url">수동 URL을 사용하여 연결</string>
<string name="permissions">권한</string>
<string name="done">완료</string>
<string name="gateway_trust_first_seen">이 게이트웨이를 신뢰하기 전에 인증서 지문을 확인하세요.\n\n%1$s</string>
<string name="gateway_trust_changed">게이트웨이 인증서가 변경되었습니다. 예상한 변경인 경우에만 계속하세요.\n\n이전 SHA-256:\n%1$s\n\n새 SHA-256:\n%2$s</string>
<string name="gateway_recovery">게이트웨이 복구</string>
</resources>
@@ -0,0 +1,37 @@
<resources>
<string name="app_name">OpenClaw Node</string>
<string name="gateway_connection">Gatewayverbinding</string>
<string name="connect_gateway">Gateway verbinden</string>
<string name="disconnect">Verbinding verbreken</string>
<string name="trust_this_gateway">Deze gateway vertrouwen?</string>
<string name="trust_and_continue">Vertrouwen en doorgaan</string>
<string name="cancel">Annuleren</string>
<string name="endpoint">Endpoint</string>
<string name="status">Status</string>
<string name="connected_gateway_ready">Je gateway is actief en klaar voor gebruik.</string>
<string name="connect_gateway_get_started">Verbind met je gateway om te beginnen.</string>
<string name="copy_report_for_claw">Rapport voor Claw kopiëren</string>
<string name="advanced_controls">Geavanceerde bediening</string>
<string name="connection_method">Verbindingsmethode</string>
<string name="setup_code">Setupcode</string>
<string name="manual">Handmatig</string>
<string name="paste_setup_code">Setupcode plakken</string>
<string name="host">Host</string>
<string name="use_tls">TLS gebruiken</string>
<string name="token_optional">Token (optioneel)</string>
<string name="password">Wachtwoord</string>
<string name="run_onboarding_again">Onboarding opnieuw uitvoeren</string>
<string name="resolved_endpoint">Opgelost endpoint</string>
<string name="gateway_setup">Gateway instellen</string>
<string name="connect_to_gateway">Verbinden met je Gateway</string>
<string name="scan_setup_code">Setupcode scannen</string>
<string name="use_gateway_qr">Gebruik je Gateway-QR-code of setupcode</string>
<string name="nearby_gateway">Gateway in de buurt</string>
<string name="enter_gateway_url">Gateway-URL invoeren</string>
<string name="connect_manual_url">Verbinden met een handmatige URL</string>
<string name="permissions">Machtigingen</string>
<string name="done">Gereed</string>
<string name="gateway_trust_first_seen">Controleer de certificaatvingerafdruk voordat je deze gateway vertrouwt.\n\n%1$s</string>
<string name="gateway_trust_changed">Het gatewaycertificaat is gewijzigd. Ga alleen door als je deze wijziging verwachtte.\n\nOude SHA-256:\n%1$s\n\nNieuwe SHA-256:\n%2$s</string>
<string name="gateway_recovery">Gatewayherstel</string>
</resources>

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