mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
Merge branch 'main' into fix/sandbox-bind-conflict
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")[]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 l’inté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 l’URL 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 l’empreinte du certificat avant d’accorder 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 l’onboarding</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 l’impronta 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
Reference in New Issue
Block a user