Compare commits
488 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d44c983f | |||
| 90c6744925 | |||
| a29b18c003 | |||
| 45d868685f | |||
| 0fdac31383 | |||
| cbed0e065c | |||
| e618794a96 | |||
| 39f7dbfe02 | |||
| c03adfb41a | |||
| 31c0b04c49 | |||
| b1bbf3fff1 | |||
| 53575f2013 | |||
| eb6fa0dacf | |||
| cb917b7f05 | |||
| 10c7ae1eca | |||
| 5dd264d2fb | |||
| 58171c8918 | |||
| cceefe833a | |||
| 0cfd448bab | |||
| 27f4ab2fb2 | |||
| 564be6b402 | |||
| 7e0b3f16e3 | |||
| ca2ae342db | |||
| d33db186d0 | |||
| da61aa8a58 | |||
| c53b11dccd | |||
| a1346a519a | |||
| 7ef6623bf3 | |||
| eaa9e1c661 | |||
| 69b2f8cd8b | |||
| df65ed7e9e | |||
| 2ed9d633b3 | |||
| a0b12f2ba7 | |||
| 712e231725 | |||
| da9f24dd2e | |||
| 47bb568cb2 | |||
| 4b4718c8df | |||
| 4e690e09c7 | |||
| d06632ba45 | |||
| d82c042b09 | |||
| bce643a0bd | |||
| a0c5e28f3b | |||
| 36b6ea1446 | |||
| 192df12d60 | |||
| 535ef8991c | |||
| 6f0b4caa26 | |||
| 78a7ff2d50 | |||
| 611dff985d | |||
| 38b6cee020 | |||
| 1aadf26f9a | |||
| 5a453eacbd | |||
| 764cd5a310 | |||
| a1628d89ec | |||
| 8483e01a68 | |||
| 01b4f42f9a | |||
| 4cb4053993 | |||
| a4408a917e | |||
| 20730af20b | |||
| 311f57a2cd | |||
| 675764e866 | |||
| b01273cfc6 | |||
| d6cbaea434 | |||
| 03159f3942 | |||
| 344f54b84d | |||
| ac03803d12 | |||
| 861b90f79c | |||
| d92fc85555 | |||
| f7041fbee3 | |||
| dc6e4a5b13 | |||
| 3f20c43308 | |||
| a81cf35a6f | |||
| a909019078 | |||
| 1087033abd | |||
| 47f52cd233 | |||
| bed69339c1 | |||
| b788616d9c | |||
| 90d426f9ad | |||
| d6eefe2e75 | |||
| 262bca9bdd | |||
| 64de4b6d6a | |||
| d0d83a2020 | |||
| fe842b5f14 | |||
| c596658b8d | |||
| 10481097f8 | |||
| f4391c1725 | |||
| 9597cf1890 | |||
| edf7ad9b7d | |||
| 9f154efa8d | |||
| c5facb8477 | |||
| cd80c7e7ff | |||
| 53e30475e2 | |||
| 0ec7711bc2 | |||
| 273973d374 | |||
| 6fd9ec97de | |||
| 08e3357480 | |||
| 15e3e63705 | |||
| b044c149c1 | |||
| 8a51891ed5 | |||
| 258d615c4d | |||
| 37a138c554 | |||
| 4894d907fa | |||
| baf1c8ea13 | |||
| cf327f60ba | |||
| d14e734e9c | |||
| d768c1f81c | |||
| 67f6a13c5a | |||
| 551647aa96 | |||
| 60bb475355 | |||
| 1708b11fab | |||
| b678308d96 | |||
| 75ed72e807 | |||
| 57334cd7d8 | |||
| 2e97d0dd95 | |||
| 773ab319ef | |||
| ecbb3bcc1a | |||
| 09f4abdd61 | |||
| 7d9397099b | |||
| 4da6a7f212 | |||
| c81e9866ff | |||
| 9a4b2266cc | |||
| f877e7e74c | |||
| 051fdcc428 | |||
| 7f863e22b0 | |||
| 16ccd5a874 | |||
| ed9cd846d0 | |||
| 03d7641b0e | |||
| 63c6080d50 | |||
| 792ce7b5b4 | |||
| 5c0255477c | |||
| d8477cbb3f | |||
| fae8de9ae0 | |||
| aa17bdbe4a | |||
| 45b5c23825 | |||
| 0f9c602591 | |||
| cc1eaf130b | |||
| 47fc6a0806 | |||
| 4380d74d49 | |||
| 820d614757 | |||
| 485cd0c512 | |||
| 14897e8de7 | |||
| 7671c1dd10 | |||
| d879c7c641 | |||
| ea1ccf4896 | |||
| f46b9c996f | |||
| 06290b49b2 | |||
| ba2eb583c0 | |||
| f413e314b9 | |||
| 8944b75e16 | |||
| 86622ebea9 | |||
| 67e9554645 | |||
| 060ede8aaa | |||
| b84d7796be | |||
| bde9cbb058 | |||
| 4e7a833a24 | |||
| bb60cab76d | |||
| 5e3a86fd2f | |||
| e8637c79b3 | |||
| 0e69660c41 | |||
| c5b89fbaea | |||
| 9203d583f9 | |||
| c0a3801086 | |||
| cb119874dc | |||
| 4d94b05ac5 | |||
| 13b4993289 | |||
| 59e5f12bf9 | |||
| 2ef109f00a | |||
| e8d1725187 | |||
| fce4d76a78 | |||
| 68b9d89ee7 | |||
| 09c7cb5d34 | |||
| b50d2ce93c | |||
| 04aa856fc0 | |||
| 103d02f98c | |||
| 56f73ae080 | |||
| 58590087de | |||
| 7e1557b8c9 | |||
| 363334253b | |||
| 4807e40cbd | |||
| 8e439e2d81 | |||
| a74067d00b | |||
| f6a854bd37 | |||
| 2e53033f22 | |||
| fe56700026 | |||
| 301fe18909 | |||
| 6a251d8d74 | |||
| 5ae367aadd | |||
| cec404225d | |||
| e1301c31e7 | |||
| 4c5a2c3c6d | |||
| 45ec5aaf2b | |||
| 8e33ebe471 | |||
| 3dbb6be270 | |||
| 1560f02561 | |||
| eb855f75ce | |||
| e45729a430 | |||
| e4915cb107 | |||
| b1533bc80c | |||
| b50c4c2c44 | |||
| 2f3b919b94 | |||
| d00ed73026 | |||
| c3a4251a60 | |||
| 6daf40d3f4 | |||
| 79659b2b14 | |||
| 62a248eb99 | |||
| 22b0f36350 | |||
| dbfdf60a42 | |||
| 296210636d | |||
| 840b768d97 | |||
| 5416cabdf8 | |||
| 65d5a91242 | |||
| ce833cd6de | |||
| 42cf32c386 | |||
| 77a3930b72 | |||
| 4c75eca580 | |||
| 081b1aa1ed | |||
| 8315c58675 | |||
| 96aad965ab | |||
| eb9a968336 | |||
| 9c142993b8 | |||
| 0ab5f4c43b | |||
| 71e45ceecc | |||
| a481ed00f5 | |||
| 1ba525f94d | |||
| 79176cc4e5 | |||
| 4b259ab81b | |||
| 00e8e88a7c | |||
| 7e7ca43a79 | |||
| 85b075d0cc | |||
| 185c393459 | |||
| 490cb5174d | |||
| 473a27470f | |||
| 7d8aeaaf06 | |||
| cf311978ea | |||
| 7b5153f214 | |||
| b402770f63 | |||
| f53e4e9ffb | |||
| c397a02c9a | |||
| 1aef45bc06 | |||
| 4b71de384c | |||
| e3385a6578 | |||
| 2ca2d5ab1c | |||
| 4fd29a35bb | |||
| 8b5ebff67b | |||
| f692288301 | |||
| 452a8c9db9 | |||
| 69590de276 | |||
| 46eba86b45 | |||
| ecb2053fdd | |||
| 125dc322f5 | |||
| 5df9aacf68 | |||
| a288f3066f | |||
| 327f0526d1 | |||
| da53015ef5 | |||
| a97cec0018 | |||
| 892a9c24b0 | |||
| eac86c2081 | |||
| cf4853e2b8 | |||
| 736ec9690f | |||
| d671d7a0a2 | |||
| 6d52b47076 | |||
| db6c513d1e | |||
| 6632fd1ea9 | |||
| 39b5ffdaa6 | |||
| 58c100f66f | |||
| 10d9549764 | |||
| 151ee6014a | |||
| 8bdda7a651 | |||
| d08dafb08f | |||
| 0ed675b1df | |||
| 0231cac957 | |||
| 3d30ba18a2 | |||
| da0ba1b73a | |||
| b096ad267e | |||
| b74be2577f | |||
| 8bf1c9a23a | |||
| fec3fdf7ef | |||
| 242188b7b1 | |||
| 199ef9f8ea | |||
| 46003e85bf | |||
| d8e2030d47 | |||
| 9925ac6a2d | |||
| caace61ba1 | |||
| e7b600e318 | |||
| 4f869b2b76 | |||
| d9ed2c425a | |||
| e273b9851e | |||
| 1ffc319831 | |||
| 97fa44dc82 | |||
| 133f14c0af | |||
| e64d72299e | |||
| 2f2110a32c | |||
| a7d56e3554 | |||
| a9d9a968ed | |||
| a690b62391 | |||
| 30fd2bbe19 | |||
| 0e3ed28950 | |||
| 58fef1d703 | |||
| dfa0b5b4fc | |||
| da6a96ed33 | |||
| a0cf753b2e | |||
| 05817187fe | |||
| c0073b3d47 | |||
| e6a5d5784c | |||
| cf4fe41957 | |||
| c289b5ff9f | |||
| 92c309f2e1 | |||
| 39d725f4d3 | |||
| 4ebefe647a | |||
| b975711429 | |||
| 96c7702526 | |||
| c5d040bbea | |||
| ac6539ed03 | |||
| a87d961ebc | |||
| f7865527af | |||
| c3f54fcddd | |||
| 39d362aeff | |||
| 18fc4c113b | |||
| d4ae8a8d34 | |||
| 8117a13dd6 | |||
| bee0c564cf | |||
| 72adf2458b | |||
| f08fe02a1b | |||
| 39a1c13635 | |||
| 91a3f0a3fe | |||
| d9b19e5970 | |||
| cf8d01bc5a | |||
| 35976da7a0 | |||
| e8197404d0 | |||
| cb3e5c35b0 | |||
| 4b5d4a4c66 | |||
| 04870a5528 | |||
| 7493f11b40 | |||
| 41314c691d | |||
| bf70614943 | |||
| 3b0298562b | |||
| 470c606dac | |||
| c7352f6b3f | |||
| 5500000492 | |||
| fdea7415cc | |||
| e915b4c64a | |||
| 02c731826a | |||
| e35fe7888b | |||
| 7af6849c2f | |||
| 9b81a53016 | |||
| 243e28df4f | |||
| f55238e72a | |||
| e4d62c21be | |||
| f789f880c9 | |||
| 8f8e2b13b4 | |||
| 8a97803474 | |||
| b37dc42240 | |||
| e16e8f5af2 | |||
| 4ada143794 | |||
| de61e9c977 | |||
| 8a006a3260 | |||
| ee594e2fdb | |||
| 1e7ec8bfd2 | |||
| 92eb3dfc9d | |||
| 75dfb71e4e | |||
| 61b3246a7f | |||
| 04d91d0319 | |||
| 53fcfdf794 | |||
| 03e689fc89 | |||
| baf656bc6f | |||
| 260bec5985 | |||
| 7b4fe6d9bc | |||
| b786d11fea | |||
| 069bbf9741 | |||
| 958cafc54f | |||
| 410ba918fb | |||
| 3175640ea2 | |||
| b49c2cbdd9 | |||
| 4a07c89816 | |||
| 8d68199793 | |||
| 00fc1f56f1 | |||
| b8bb8ab3ca | |||
| 347f7b9550 | |||
| 1f004e6640 | |||
| acbb93be48 | |||
| 0cc3e8137c | |||
| 95c6b3a912 | |||
| ce8c67c314 | |||
| 5e1bfb2ce2 | |||
| 8c701ba1ff | |||
| 3cd3d489f4 | |||
| ec45c317f5 | |||
| 6fb082e131 | |||
| 70e31c6f68 | |||
| c0026274d9 | |||
| f312222159 | |||
| aaeed3c4ea | |||
| 20c2db2103 | |||
| 8f8e46d898 | |||
| 876018f322 | |||
| 4258a3307f | |||
| aedf62ac7e | |||
| c736f11a16 | |||
| f41715a18f | |||
| 046feb6b0e | |||
| 496a76c03b | |||
| e56b0cf1a0 | |||
| c6dfa26f03 | |||
| f83719937a | |||
| a0a229a3bb | |||
| 39cc547f74 | |||
| b090d6019b | |||
| 42f455739f | |||
| eb73e87f18 | |||
| 8d1481cb4a | |||
| 2aa7842ade | |||
| ef326f5cd0 | |||
| 15cfba7075 | |||
| 2011edc9e5 | |||
| 125f4071bc | |||
| 45d59971e6 | |||
| 5325ed90b2 | |||
| f60d9591ef | |||
| d512163d68 | |||
| 8f3310000a | |||
| a898acbd55 | |||
| 98292331d5 | |||
| c7f88e85b7 | |||
| 15240bdbfe | |||
| 975c9f4b54 | |||
| b12216af93 | |||
| 2b7db53d06 | |||
| 285a0f48e5 | |||
| f729cc7b07 | |||
| 10a1593e0c | |||
| f9c3fdba45 | |||
| 434dc46531 | |||
| 73677f2707 | |||
| 6798330c24 | |||
| 3d29233bab | |||
| 90ddb3f271 | |||
| 3607b733cb | |||
| a182afcf97 | |||
| ae658aa84c | |||
| 97eb5542e8 | |||
| b3f46f0e28 | |||
| ed34129637 | |||
| 036e3e633e | |||
| 9c1c083d98 | |||
| 7725c0b9b3 | |||
| 959cbafcdb | |||
| f894c23e64 | |||
| 2e3c05d9da | |||
| 56b8c69487 | |||
| a0fa283839 | |||
| fb76e316fb | |||
| 6bc7544a6a | |||
| 81752564e9 | |||
| b065265b73 | |||
| 41870fac16 | |||
| f701224a69 | |||
| 35a4641bb6 | |||
| 1c0c58e30d | |||
| e5399835b2 | |||
| b247cd6d65 | |||
| a6337be3d1 | |||
| def28a87b2 | |||
| 177386ed73 | |||
| 8f5f599a34 | |||
| 52d933b3a9 | |||
| 6e97470515 | |||
| b564b72dc9 | |||
| c1964e73a8 | |||
| 24a60799be | |||
| 2e84017f23 | |||
| 156f13aa64 | |||
| f7de41ca20 | |||
| 19d2a8998b | |||
| 107bda27c9 | |||
| d1bed505c5 | |||
| 0a58328217 | |||
| 09200b3c10 | |||
| 38c4944d76 | |||
| 146c92069b | |||
| 9beec48e9c | |||
| dd6ad0da8c | |||
| 2652bb1d7d | |||
| d942e5924e | |||
| 1edd9f8bf5 | |||
| 797843c39a | |||
| ff4dc050cc | |||
| 6969027025 | |||
| d2597d5ecf | |||
| 480cc4b85c |
@@ -240,6 +240,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -3,6 +3,8 @@ name: Auto response
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
@@ -42,6 +44,7 @@ jobs:
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
@@ -55,11 +58,76 @@ jobs:
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
commentTriggers: ["moltbook"],
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
const maintainerTeam = "maintainer";
|
||||
const pingWarningMessage =
|
||||
"Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
|
||||
const mentionRegex = /@([A-Za-z0-9-]+)/g;
|
||||
const maintainerCache = new Map();
|
||||
const normalizeLogin = (login) => login.toLowerCase();
|
||||
|
||||
const isMaintainer = async (login) => {
|
||||
if (!login) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeLogin(login);
|
||||
if (maintainerCache.has(normalized)) {
|
||||
return maintainerCache.get(normalized);
|
||||
}
|
||||
let isMember = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: maintainerTeam,
|
||||
username: normalized,
|
||||
});
|
||||
isMember = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
maintainerCache.set(normalized, isMember);
|
||||
return isMember;
|
||||
};
|
||||
|
||||
const countMaintainerMentions = async (body, authorLogin) => {
|
||||
if (!body) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
|
||||
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const haystack = body.toLowerCase();
|
||||
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
|
||||
if (haystack.includes(teamMention)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
const mentions = new Set();
|
||||
for (const match of body.matchAll(mentionRegex)) {
|
||||
mentions.add(normalizeLogin(match[1]));
|
||||
}
|
||||
if (normalizedAuthor) {
|
||||
mentions.delete(normalizedAuthor);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const login of mentions) {
|
||||
if (await isMaintainer(login)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
@@ -72,6 +140,63 @@ jobs:
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const comment = context.payload.comment;
|
||||
if (comment) {
|
||||
const authorLogin = comment.user?.login ?? "";
|
||||
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentBody = comment.body ?? "";
|
||||
const responses = [];
|
||||
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
|
||||
if (mentionCount >= 3) {
|
||||
responses.push(pingWarningMessage);
|
||||
}
|
||||
|
||||
const commentHaystack = commentBody.toLowerCase();
|
||||
const commentRule = rules.find((item) =>
|
||||
(item.commentTriggers ?? []).some((trigger) =>
|
||||
commentHaystack.includes(trigger),
|
||||
),
|
||||
);
|
||||
if (commentRule) {
|
||||
responses.push(commentRule.message);
|
||||
}
|
||||
|
||||
if (responses.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
body: responses.join("\n\n"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const action = context.payload.action;
|
||||
if (action === "opened" || action === "edited") {
|
||||
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
const authorLogin = issue.user?.login ?? "";
|
||||
const mentionCount = await countMaintainerMentions(
|
||||
issueText,
|
||||
authorLogin,
|
||||
);
|
||||
if (mentionCount >= 3) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: pingWarningMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
@@ -94,7 +219,6 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
@@ -136,7 +260,6 @@ jobs:
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -418,12 +418,23 @@ jobs:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -495,6 +506,12 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
@@ -512,7 +529,7 @@ jobs:
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
@@ -172,6 +172,9 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
@@ -207,6 +208,7 @@
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`openclaw` binaries resolve when invoked via `openclaw-mac`.
|
||||
- For manual `openclaw message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
|
||||
|
||||
+189
-1
@@ -2,7 +2,172 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.24 (Unreleased)
|
||||
## 2026.2.26 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Highlight: External Secrets Management introduces a full `openclaw secrets` workflow (`audit`, `configure`, `apply`, `reload`) with runtime snapshot activation, strict `secrets apply` target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.
|
||||
- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
|
||||
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
|
||||
- Codex/WebSocket transport: make `openai-codex` WebSocket-first by default (`transport: "auto"` with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.
|
||||
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
|
||||
- Android/Nodes: add Android `device` capability plus `device.status` and `device.info` node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.
|
||||
- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
|
||||
- Docs/Contributing: add Nimrod Gutman to the maintainer roster in `CONTRIBUTING.md`. (#27840) Thanks @ngutman.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.
|
||||
- Google Chat/Lifecycle: keep Google Chat `startAccount` pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.
|
||||
- Temp dirs/Linux umask: force `0700` permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so `umask 0002` installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.
|
||||
- Nextcloud Talk/Lifecycle: keep `startAccount` pending until abort and stop the webhook monitor on shutdown, preventing `EADDRINUSE` restart loops when the gateway manages account lifecycle. (#27897)
|
||||
- Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.
|
||||
- Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)
|
||||
- Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.
|
||||
- Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.
|
||||
- Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.
|
||||
- Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.
|
||||
- Telegram/Webhook startup: clarify webhook config guidance, allow `channels.telegram.webhookPort: 0` for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.
|
||||
- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
|
||||
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
|
||||
- Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.
|
||||
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
|
||||
- Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.
|
||||
- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)
|
||||
- Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.
|
||||
- Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)
|
||||
- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.
|
||||
- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.
|
||||
- NO_REPLY suppression: suppress `NO_REPLY` before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)
|
||||
- Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (`BodyForAgent`) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.
|
||||
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
|
||||
- Auto-reply/Inbound metadata: add a readable `timestamp` field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.
|
||||
- Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding `triggerTyping()` with `runComplete`, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.
|
||||
- Typing/Dispatch idle: force typing cleanup when `markDispatchIdle` never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)
|
||||
- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
|
||||
- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673.
|
||||
- Browser/Extension relay CORS: handle `/json*` `OPTIONS` preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)
|
||||
- Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)
|
||||
- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
|
||||
- Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.
|
||||
- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.
|
||||
- Feishu/Inbound message metadata: include inbound `message_id` in `BodyForAgent` on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.
|
||||
- Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.
|
||||
- LINE/Inline directives auth: gate directive parsing (`/model`, `/think`, `/verbose`, `/reasoning`, `/queue`) on resolved authorization (`command.isAuthorizedSender`) so `commands.allowFrom`-authorized LINE senders are not silently stripped when raw `CommandAuthorized` is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)
|
||||
- Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
|
||||
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
|
||||
- CLI/Gateway `--force` in non-root Docker: recover from `lsof` permission failures (`EACCES`/`EPERM`) by falling back to `fuser` kill + probe-based port checks, so `openclaw gateway --force` works for default container `node` user flows. (#27941)
|
||||
- Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.
|
||||
- Sessions cleanup/Doctor: add `openclaw sessions cleanup --fix-missing` to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)
|
||||
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
|
||||
- CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
|
||||
- CLI/Gateway auth: align `gateway run --auth` parsing/help text with supported gateway auth modes by accepting `none` and `trusted-proxy` (in addition to `token`/`password`) for CLI overrides. (#27469) thanks @s1korrrr.
|
||||
- CLI/Daemon status TLS probe: use `wss://` and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so `openclaw daemon status` works with `gateway.bind=lan` + `gateway.tls.enabled=true`. (#24234) thanks @liuy.
|
||||
- Podman/Default bind: change `run-openclaw-podman.sh` default gateway bind from `lan` to `loopback` and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.
|
||||
- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn.
|
||||
- Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before `/restart` launchctl/systemctl triggers, and set LaunchAgent `ThrottleInterval=60` to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)
|
||||
- Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)
|
||||
- Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker.
|
||||
- Models/Profile suffix parsing: centralize trailing `@profile` parsing and only treat `@` as a profile separator when it appears after the final `/`, preserving model IDs like `openai/@cf/...` and `openrouter/@preset/...` across `/model` directive parsing and allowlist model resolution, with regression coverage.
|
||||
- Models/OpenAI Codex config schema parity: accept `openai-codex-responses` in the config model API schema and TypeScript `ModelApi` union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.
|
||||
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
|
||||
- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
|
||||
- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
|
||||
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Security/Node exec approvals hardening: freeze immutable approval-time execution plans (`argv`/`cwd`/`agentId`/`sessionKey`) via `system.run.prepare`, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
|
||||
- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation.
|
||||
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
|
||||
- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
|
||||
- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng.
|
||||
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
|
||||
- Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)
|
||||
- Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)
|
||||
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
|
||||
- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.
|
||||
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
|
||||
- Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.
|
||||
- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
|
||||
- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics.
|
||||
- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
|
||||
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
|
||||
- Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)
|
||||
- Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl.
|
||||
- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
|
||||
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
|
||||
- Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.
|
||||
- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
|
||||
- LINE/Lifecycle: keep LINE `startAccount` pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.
|
||||
- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
|
||||
- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
|
||||
- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
|
||||
- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
|
||||
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
|
||||
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
|
||||
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
|
||||
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
|
||||
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
|
||||
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
|
||||
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007)
|
||||
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
|
||||
- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
|
||||
- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
|
||||
- Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.
|
||||
- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed.
|
||||
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
|
||||
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
|
||||
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.26`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.26`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
|
||||
- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
|
||||
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
|
||||
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
|
||||
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
|
||||
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -92,6 +257,27 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
|
||||
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
|
||||
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
|
||||
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
|
||||
- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
|
||||
- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
|
||||
- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
|
||||
- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
|
||||
- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
|
||||
- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||
- CLI/Memory search: accept `--query <text>` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
## 2026.2.23
|
||||
@@ -142,6 +328,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope.
|
||||
- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese.
|
||||
- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm.
|
||||
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
|
||||
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
|
||||
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
|
||||
|
||||
@@ -32,6 +32,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Mariano Belinky** - iOS app, Security
|
||||
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
|
||||
|
||||
- **Nimrod Gutman** - iOS app, macOS app and crustacean features
|
||||
- GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti)
|
||||
|
||||
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
|
||||
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
|
||||
|
||||
@@ -50,6 +53,12 @@ Welcome to the lobster tank! 🦞
|
||||
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
|
||||
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
|
||||
|
||||
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
|
||||
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ COPY --chown=node:node patches ./patches
|
||||
COPY --chown=node:node scripts ./scripts
|
||||
|
||||
USER node
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# OpenClaw PR Submission Status
|
||||
|
||||
> Auto-maintained by agent team. Last updated: 2026-02-22
|
||||
|
||||
## PR Plan Overview
|
||||
|
||||
All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`.
|
||||
Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md).
|
||||
|
||||
## Duplicate Check
|
||||
|
||||
Before submission, each PR was cross-referenced against:
|
||||
|
||||
- 100+ open upstream PRs (as of 2026-02-22)
|
||||
- 50 recently merged PRs
|
||||
- 50+ open issues
|
||||
|
||||
No overlap found with existing PRs.
|
||||
|
||||
## PR Status Table
|
||||
|
||||
| # | Branch | Title | Type | Status | PR URL |
|
||||
| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- |
|
||||
| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) |
|
||||
| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) |
|
||||
| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) |
|
||||
| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) |
|
||||
| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) |
|
||||
| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) |
|
||||
| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) |
|
||||
| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) |
|
||||
| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) |
|
||||
| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) |
|
||||
| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) |
|
||||
|
||||
## Isolation Rules
|
||||
|
||||
- Each agent works on a separate git worktree branch
|
||||
- No two agents modify the same file
|
||||
- File ownership:
|
||||
- PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts`
|
||||
- PR 2: `src/agents/session-slug.ts`
|
||||
- PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts`
|
||||
- PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts`
|
||||
- PR 5: `src/signal/client.ts`, `src/imessage/client.ts`
|
||||
- PR 6: `src/media/store.ts`, `src/commands/signal-install.ts`
|
||||
- PR 7: `src/telegram/bot-message-dispatch.ts`
|
||||
- PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts`
|
||||
- PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts`
|
||||
- PR 10: `src/agents/skills-install-download.ts`
|
||||
- PR 11: `src/browser/extension-relay.ts`
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Batch 1 (PRs 1-4) — All CI Green
|
||||
|
||||
- PR 1: 17 tests pass, check/build/tests all green
|
||||
- PR 2: 3 tests pass, check/build/tests all green
|
||||
- PR 3: 45 tests pass (3 new), check/build/tests all green
|
||||
- PR 4: 12 tests pass, check/build/tests all green
|
||||
|
||||
### Batch 2 (PRs 5-7) — CI Running
|
||||
|
||||
- PR 5: 3 signal tests pass, check pass, awaiting full test suite
|
||||
- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite
|
||||
- PR 7: 47 tests pass (3 new), check pass, awaiting full suite
|
||||
|
||||
### Batch 3 (PRs 8-9) — All CI Green
|
||||
|
||||
- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test.
|
||||
- PR 8: 17/17 pass, check/build/tests/windows all green
|
||||
- PR 9: 18/18 pass, check/build/tests/windows all green
|
||||
|
||||
### Batch 4 (PRs 10-11) — All CI Green
|
||||
|
||||
- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9).
|
||||
- PR 10: 19/19 pass, check/build/tests/windows all green
|
||||
- PR 11: 20/20 pass, check/build/tests/windows all green
|
||||
+16
@@ -41,6 +41,7 @@ For fastest triage, include all of the following:
|
||||
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
|
||||
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
|
||||
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
|
||||
- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities.
|
||||
|
||||
Reports that miss these requirements may be closed as `invalid` or `no-action`.
|
||||
|
||||
@@ -51,11 +52,14 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope).
|
||||
- Operator-intended local features (for example TUI local `!` shell) presented as remote injection.
|
||||
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
|
||||
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
|
||||
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
||||
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
|
||||
|
||||
### Duplicate Report Handling
|
||||
@@ -93,6 +97,14 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
|
||||
- Implicit exec calls (no explicit host in the tool call) follow the same behavior.
|
||||
- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy.
|
||||
|
||||
## Trusted Plugin Concept (Core)
|
||||
|
||||
Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
|
||||
- Installing or enabling a plugin grants it the same trust level as local code running on that gateway host.
|
||||
- Plugin behavior such as reading env/files or running host commands is expected inside this trust boundary.
|
||||
- Security reports must show a boundary bypass (for example unauthenticated plugin load, allowlist/policy bypass, or sandbox/path-safety bypass), not only malicious behavior from a trusted-installed plugin.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Public Internet Exposure
|
||||
@@ -101,10 +113,13 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
|
||||
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
|
||||
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
|
||||
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
|
||||
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
|
||||
|
||||
## Deployment Assumptions
|
||||
|
||||
@@ -140,6 +155,7 @@ OpenClaw separates routing from execution, but both remain inside the same opera
|
||||
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
|
||||
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
|
||||
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
|
||||
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
|
||||
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
|
||||
|
||||
## Workspace Memory Trust Boundary
|
||||
|
||||
+65
-87
@@ -209,106 +209,84 @@
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.24</title>
|
||||
<pubDate>Wed, 25 Feb 2026 02:59:30 +0000</pubDate>
|
||||
<title>2026.2.25</title>
|
||||
<pubDate>Thu, 26 Feb 2026 05:14:17 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>14728</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.24</sparkle:shortVersionString>
|
||||
<sparkle:version>14883</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.25</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.24</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.25</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Auto-reply/Abort shortcuts: expand standalone stop phrases (<code>stop openclaw</code>, <code>stop action</code>, <code>stop run</code>, <code>stop agent</code>, <code>please stop</code>, and related variants), accept trailing punctuation (for example <code>STOP OPENCLAW!!!</code>), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact <code>do not do that</code> as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.</li>
|
||||
<li>Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.</li>
|
||||
<li>Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.</li>
|
||||
<li>Security/Audit: add <code>security.trust_model.multi_user_heuristic</code> to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (<code>sandbox.mode="all"</code>, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).</li>
|
||||
<li>Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping <code>@buape/carbon</code> pinned.</li>
|
||||
<li>Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.</li>
|
||||
<li>Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.</li>
|
||||
<li>UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.</li>
|
||||
<li>Heartbeat/Config: replace heartbeat DM toggle with <code>agents.defaults.heartbeat.directPolicy</code> (<code>allow</code> | <code>block</code>; also supported per-agent via <code>agents.list[].heartbeat.directPolicy</code>) for clearer delivery semantics.</li>
|
||||
<li>Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.</li>
|
||||
<li>Branding/Docs + Apple surfaces: replace remaining <code>bot.molt</code> launchd label, bundle-id, logging subsystem, and command examples with <code>ai.openclaw</code> across docs, iOS app surfaces, helper scripts, and CLI test fixtures.</li>
|
||||
<li>Agents/Config: remind agents to call <code>config.schema</code> before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.</li>
|
||||
<li>Dependencies: update workspace dependency pins and lockfile (Bedrock SDK <code>3.998.0</code>, <code>@mariozechner/pi-*</code> <code>0.55.1</code>, TypeScript native preview <code>7.0.0-dev.20260225.1</code>) while keeping <code>@buape/carbon</code> pinned.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example <code>user:<id></code>, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.</li>
|
||||
<li><strong>BREAKING:</strong> Security/Sandbox: block Docker <code>network: "container:<id>"</code> namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set <code>agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true</code> (break-glass). Thanks @tdjackey for reporting.</li>
|
||||
<li><strong>BREAKING:</strong> Heartbeat direct/DM delivery default is now <code>allow</code> again. To keep DM-blocked behavior from <code>2026.2.24</code>, set <code>agents.defaults.heartbeat.directPolicy: "block"</code> (or per-agent override).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (<code>channel</code>/<code>to</code>/<code>thread</code>) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.</li>
|
||||
<li>Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.</li>
|
||||
<li>Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)</li>
|
||||
<li>Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from <code>last</code> to <code>none</code> (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)</li>
|
||||
<li>Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.</li>
|
||||
<li>Cron/Heartbeat delivery: stop inheriting cached session <code>lastThreadId</code> for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.</li>
|
||||
<li>Messaging tool dedupe: treat originating channel metadata as authoritative for same-target <code>message.send</code> suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so <code>delivery-mirror</code> transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.</li>
|
||||
<li>Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.</li>
|
||||
<li>Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.</li>
|
||||
<li>Gateway/Models: honor explicit <code>agents.defaults.models</code> allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in <code>models.list</code>, and allow <code>sessions.patch</code>/<code>/model</code> selection for those refs without false <code>model not allowed</code> errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.</li>
|
||||
<li>Control UI/Agents: inherit <code>agents.defaults.model.fallbacks</code> in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.</li>
|
||||
<li>Automation/Subagent/Cron reliability: honor <code>ANNOUNCE_SKIP</code> in <code>sessions_spawn</code> completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include <code>cron</code> in the <code>coding</code> tool profile so <code>/tools/invoke</code> can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.</li>
|
||||
<li>Discord/Voice reliability: restore runtime DAVE dependency (<code>@snazzah/davey</code>), add configurable DAVE join options (<code>channels.discord.voice.daveEncryption</code> and <code>channels.discord.voice.decryptionFailureTolerance</code>), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)</li>
|
||||
<li>Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all <code>block</code> payloads), fixing missing Discord replies in <code>channels.discord.streaming=block</code> mode. (#25839, #25836, #25792) Thanks @pewallin.</li>
|
||||
<li>Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire <code>messages.statusReactions.{emojis,timing}</code> into Discord reaction lifecycle control, and compact model-picker <code>custom_id</code> keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.</li>
|
||||
<li>WhatsApp/Web reconnect: treat close status <code>440</code> as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.</li>
|
||||
<li>WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with <code>Reasoning:</code> before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)</li>
|
||||
<li>Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.</li>
|
||||
<li>Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.</li>
|
||||
<li>Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.</li>
|
||||
<li>Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram <code>autoSelectFamily</code> decisions so outbound <code>fetch</code> calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.</li>
|
||||
<li>Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.</li>
|
||||
<li>Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.</li>
|
||||
<li>Slack/DM routing: treat <code>D*</code> channel IDs as direct messages even when Slack sends an incorrect <code>channel_type</code>, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.</li>
|
||||
<li>Zalo/Group policy: enforce sender authorization for group messages with <code>groupPolicy</code> + <code>groupAllowFrom</code> (fallback to <code>allowFrom</code>), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.</li>
|
||||
<li>macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.</li>
|
||||
<li>macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.</li>
|
||||
<li>macOS/Voice wake routing: default forwarded voice-wake transcripts to the <code>webchat</code> channel (instead of ambiguous <code>last</code> routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.</li>
|
||||
<li>macOS/Gateway launch: prefer an available <code>openclaw</code> binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.</li>
|
||||
<li>macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.</li>
|
||||
<li>macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.</li>
|
||||
<li>Windows/Exec shell selection: prefer PowerShell 7 (<code>pwsh</code>) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing <code>&&</code> command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.</li>
|
||||
<li>Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 <code>dev=0</code> stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false <code>Local media path is not safe to read</code> drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.</li>
|
||||
<li>iMessage/Reasoning safety: harden iMessage echo suppression with outbound <code>messageId</code> matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.</li>
|
||||
<li>Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.</li>
|
||||
<li>Providers/Google reasoning: sanitize invalid negative <code>thinkingBudget</code> payloads for Gemini 3.1 requests by dropping <code>-1</code> budgets and mapping configured reasoning effort to <code>thinkingLevel</code>, preventing malformed reasoning payloads on <code>google-generative-ai</code>. (#25900)</li>
|
||||
<li>Providers/SiliconFlow: normalize <code>thinking="off"</code> to <code>thinking: null</code> for <code>Pro/*</code> model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.</li>
|
||||
<li>Models/Bedrock auth: normalize additional Bedrock provider aliases (<code>bedrock</code>, <code>aws-bedrock</code>, <code>aws_bedrock</code>, <code>amazon bedrock</code>) to canonical <code>amazon-bedrock</code>, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.</li>
|
||||
<li>Models/Providers: preserve explicit user <code>reasoning</code> overrides when merging provider model config with built-in catalog metadata, so <code>reasoning: false</code> is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.</li>
|
||||
<li>Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false <code>pairing required</code> failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.</li>
|
||||
<li>CLI/Memory search: accept <code>--query <text></code> for <code>openclaw memory search</code> (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.</li>
|
||||
<li>CLI/Doctor: correct stale recovery hints to use valid commands (<code>openclaw gateway status --deep</code> and <code>openclaw configure --section model</code>). (#24485) Thanks @chilu18.</li>
|
||||
<li>Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.</li>
|
||||
<li>Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid <code>plugins.entries.<channelId></code> writes when ids differ. (#25275) Thanks @zerone0x.</li>
|
||||
<li>Config/Plugins: treat stale removed <code>google-antigravity-auth</code> plugin references as compatibility warnings (not hard validation errors) across <code>plugins.entries</code>, <code>plugins.allow</code>, <code>plugins.deny</code>, and <code>plugins.slots.memory</code>, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.</li>
|
||||
<li>Config/Meta: accept numeric <code>meta.lastTouchedAt</code> timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write <code>Date.now()</code> values. (#25491) Thanks @mcaxtr.</li>
|
||||
<li>Usage accounting: parse Moonshot/Kimi <code>cached_tokens</code> fields (including <code>prompt_tokens_details.cached_tokens</code>) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.</li>
|
||||
<li>Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.</li>
|
||||
<li>Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit <code>status/code/http 402</code> detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.</li>
|
||||
<li>Sessions/Tool-result guard: avoid generating synthetic <code>toolResult</code> entries for assistant turns that ended with <code>stopReason: "aborted"</code> or <code>"error"</code>, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.</li>
|
||||
<li>Auto-reply/Reset hooks: guarantee native <code>/new</code> and <code>/reset</code> flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.</li>
|
||||
<li>Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.</li>
|
||||
<li>Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.</li>
|
||||
<li>Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not <code>; </code> joins) to avoid POSIX <code>sh</code> <code>do;</code> syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.</li>
|
||||
<li>Sandbox/Config: preserve <code>dangerouslyAllowReservedContainerTargets</code> and <code>dangerouslyAllowExternalBindSources</code> during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.</li>
|
||||
<li>Gateway/Security: enforce gateway auth for the exact <code>/api/channels</code> plugin root path (plus <code>/api/channels/</code> descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.</li>
|
||||
<li>Exec approvals: treat bare allowlist <code>*</code> as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.</li>
|
||||
<li>iOS/Signing: improve <code>scripts/ios-team-id.sh</code> for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode <code>xcodebuild</code> output directories (<code>apps/ios/build</code>, <code>apps/shared/OpenClawKit/build</code>, <code>Swabble/build</code>). (#22773) Thanks @brianleach.</li>
|
||||
<li>Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.</li>
|
||||
<li>Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (<code>LD_*</code>, <code>DYLD_*</code>, <code>SSLKEYLOGFILE</code>, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.</li>
|
||||
<li>Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width <code>HOOK:...</code>) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.</li>
|
||||
<li>Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.</li>
|
||||
<li>Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host <code>os.tmpdir()</code> trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.</li>
|
||||
<li>Security/Message actions: enforce local media root checks for <code>sendAttachment</code> and <code>setGroupIcon</code> when <code>sandboxRoot</code> is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.</li>
|
||||
<li>Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.</li>
|
||||
<li>Security/Workspace FS: normalize <code>@</code>-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so <code>dmPolicy: "allowlist"</code> with empty <code>allowedUserIds</code> rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.</li>
|
||||
<li>Security/Native images: enforce <code>tools.fs.workspaceOnly</code> for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: bind <code>system.run</code> command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only <code>rawCommand</code> mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec companion host: forward canonical <code>system.run</code> display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested <code>/usr/bin/env</code> chains cannot bypass shell-wrapper approval gating in <code>allowlist</code> + <code>ask=on-miss</code> mode. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec: limit default safe-bin trusted directories to immutable system paths (<code>/bin</code>, <code>/usr/bin</code>) and require explicit opt-in (<code>tools.exec.safeBinTrustedDirs</code>) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured <code>safeBins</code> resolve outside trusted dirs. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.</li>
|
||||
<li>Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without <code>message_id</code> as delivery failures (instead of false-success <code>"unknown"</code> IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.</li>
|
||||
<li>Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)</li>
|
||||
<li>Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable <code>session.parentForkMaxTokens</code> (default <code>100000</code>, <code>0</code> disables). (#26912) Thanks @markshields-tl.</li>
|
||||
<li>Cron/Message multi-account routing: honor explicit <code>delivery.accountId</code> for isolated cron delivery resolution, and when <code>message.send</code> omits <code>accountId</code>, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.</li>
|
||||
<li>Gateway/Message media roots: thread <code>agentId</code> through gateway <code>send</code> RPC and prefer explicit <code>agentId</code> over session/default resolution so non-default agent workspace media sends no longer fail with <code>LocalMediaAccessError</code>; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.</li>
|
||||
<li>Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.</li>
|
||||
<li>Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed <code>delivered</code>, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)</li>
|
||||
<li>LINE/Lifecycle: keep LINE <code>startAccount</code> pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.</li>
|
||||
<li>Discord/Gateway: capture and drain startup-time gateway <code>error</code> events before lifecycle listeners attach so early <code>Fatal Gateway error: 4014</code> closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.</li>
|
||||
<li>Discord/Inbound text: preserve embed <code>title</code> + <code>description</code> fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.</li>
|
||||
<li>Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to <code>file</code> so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.</li>
|
||||
<li>Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)</li>
|
||||
<li>Telegram/Markdown spoilers: keep valid <code>||spoiler||</code> pairs while leaving unmatched trailing <code>||</code> delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.</li>
|
||||
<li>Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example <code>c0abc12345</code>) correctly match Slack runtime IDs (<code>C0ABC12345</code>) under <code>groupPolicy: "allowlist"</code>, preventing silent channel-event drops. (#26878) Thanks @lbo728.</li>
|
||||
<li>Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.</li>
|
||||
<li>Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.</li>
|
||||
<li>Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including <code>NO_REPLY</code>, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.</li>
|
||||
<li>Voice-call/TTS tools: hide the <code>tts</code> tool when the message provider is <code>voice</code>, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)</li>
|
||||
<li>Agents/Tools: normalize non-standard plugin tool results that omit <code>content</code> so embedded runs no longer crash with <code>Cannot read properties of undefined (reading 'filter')</code> after tool completion (including <code>tesseramemo_query</code>). (#27007)</li>
|
||||
<li>Cron/Model overrides: when isolated <code>payload.model</code> is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.</li>
|
||||
<li>Agents/Model fallback: keep explicit text + image fallback chains reachable even when <code>agents.defaults.models</code> allowlists are present, prefer explicit run <code>agentId</code> over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify <code>model_cooldown</code> / <code>cooling down</code> errors as <code>rate_limit</code> so failover continues. (#11972, #24137, #17231)</li>
|
||||
<li>Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of <code>disabledReason</code> only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for <code>rate_limit</code>. (#23816) thanks @ramezgaberiel.</li>
|
||||
<li>Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.</li>
|
||||
<li>Models/Auth probes: map permanent auth failover reasons (<code>auth_permanent</code>, for example revoked keys) into probe auth status instead of <code>unknown</code>, so <code>openclaw models status --probe</code> reports actionable auth failures. (#25754) thanks @rrenamed.</li>
|
||||
<li>Hooks/Inbound metadata: include <code>guildId</code> and <code>channelName</code> in <code>message_received</code> metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.</li>
|
||||
<li>Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get <code>CommandAuthorized: true</code> on modal/button events. (#26119) Thanks @bmendonca3.</li>
|
||||
<li>Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (<code>2026.2.25</code>). Thanks @luz-oasis for reporting.</li>
|
||||
<li>Security/Gateway trusted proxy: require <code>operator</code> role for the Control UI trusted-proxy pairing bypass so unpaired <code>node</code> sessions can no longer connect via <code>client.id=control-ui</code> and invoke node event methods. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy <code>oauth.json</code> onboarding path that exposed the PKCE verifier via OAuth <code>state</code>; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (<code>2026.2.25</code>). Thanks @zdi-disclosures for reporting.</li>
|
||||
<li>Security/Microsoft Teams file consent: bind <code>fileConsent/invoke</code> upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked <code>uploadId</code> values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Gateway: harden <code>agents.files</code> path handling to block out-of-workspace symlink targets for <code>agents.files.get</code>/<code>agents.files.set</code>, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Workspace FS: reject hardlinked workspace file aliases in <code>tools.fs.workspaceOnly</code> and <code>tools.exec.applyPatch.workspaceOnly</code> boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before <code>setFiles</code>, with regression coverage for strict missing-path handling.</li>
|
||||
<li>Security/Exec approvals: bind <code>system.run</code> approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: harden approval-bound <code>system.run</code> execution on node hosts by rejecting symlink <code>cwd</code> paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under <code>dmPolicy</code>/<code>groupPolicy</code>; reaction notifications now require channel access checks first. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild <code>groupPolicy</code> channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Slack reactions + pins: gate <code>reaction_*</code> and <code>pin_*</code> system-event enqueue through shared sender authorization so DM <code>dmPolicy</code>/<code>allowFrom</code> and channel <code>users</code> allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Telegram reactions: enforce <code>dmPolicy</code>/<code>allowFrom</code> and group allowlist authorization on <code>message_reaction</code> events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Slack interactions: enforce channel/DM authorization and modal actor binding (<code>private_metadata.userId</code>) before enqueueing <code>block_action</code>/<code>view_submission</code>/<code>view_closed</code> system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.</li>
|
||||
<li>Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.</li>
|
||||
<li>Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.</li>
|
||||
<li>Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.</li>
|
||||
<li>Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.</li>
|
||||
<li>Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.</li>
|
||||
<li>Security/SSRF guard: classify IPv6 multicast literals (<code>ff00::/8</code>) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (<code>2026.2.25</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Tests/Low-memory stability: disable Vitest <code>vmForks</code> by default on low-memory local hosts (<code><64 GiB</code>), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with <code>setSessionRuntimeModel</code> usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.24/OpenClaw-2026.2.24.zip" length="23253502" type="application/octet-stream" sparkle:edSignature="acl6Y8HLA1Ar6WGVkgMQmDUm5F02tNwbjpDZe91LnqNWy68jAtVOplTnCXYPsiEcpHeykYhXS5cK5r0tN8v7AA=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.25/OpenClaw-2026.2.25.zip" length="23078398" type="application/octet-stream" sparkle:edSignature="PJjvRhivhybV5bYr8u1C9Dyw4h8yePGwG8SFsr4QRqMSBYMEedraPJO3KNbkoChjclYUYf3oGcC4daNZnFvgBA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -34,6 +34,90 @@ cd apps/android
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Macrobenchmark (Startup + Frame Timing)
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :benchmark:connectedDebugAndroidTest
|
||||
```
|
||||
|
||||
Reports are written under:
|
||||
|
||||
- `apps/android/benchmark/build/reports/androidTests/connected/`
|
||||
|
||||
## Perf CLI (low-noise)
|
||||
|
||||
Deterministic startup measurement + hotspot extraction with compact CLI output:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./scripts/perf-startup-benchmark.sh
|
||||
./scripts/perf-startup-hotspots.sh
|
||||
```
|
||||
|
||||
Benchmark script behavior:
|
||||
|
||||
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
|
||||
- Prints median/min/max/COV in one line.
|
||||
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
|
||||
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
|
||||
|
||||
Hotspot script behavior:
|
||||
|
||||
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
|
||||
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
|
||||
- Writes raw `perf.data` path for deeper follow-up if needed.
|
||||
|
||||
## Run on a Real Android Phone (USB)
|
||||
|
||||
1) On phone, enable **Developer options** + **USB debugging**.
|
||||
2) Connect by USB and accept the debugging trust prompt on phone.
|
||||
3) Verify ADB can see the device:
|
||||
|
||||
```bash
|
||||
adb devices -l
|
||||
```
|
||||
|
||||
4) Install + launch debug build:
|
||||
|
||||
```bash
|
||||
pnpm android:install
|
||||
pnpm android:run
|
||||
```
|
||||
|
||||
If `adb devices -l` shows `unauthorized`, re-plug and accept the trust prompt again.
|
||||
|
||||
### USB-only gateway testing (no LAN dependency)
|
||||
|
||||
Use `adb reverse` so Android `localhost:18789` tunnels to your laptop `localhost:18789`.
|
||||
|
||||
Terminal A (gateway):
|
||||
|
||||
```bash
|
||||
pnpm openclaw gateway --port 18789 --verbose
|
||||
```
|
||||
|
||||
Terminal B (USB tunnel):
|
||||
|
||||
```bash
|
||||
adb reverse tcp:18789 tcp:18789
|
||||
```
|
||||
|
||||
Then in app **Connect → Manual**:
|
||||
|
||||
- Host: `127.0.0.1`
|
||||
- Port: `18789`
|
||||
- TLS: off
|
||||
|
||||
## Hot Reload / Fast Iteration
|
||||
|
||||
This app is native Kotlin + Jetpack Compose.
|
||||
|
||||
- For Compose UI edits: use Android Studio **Live Edit** on a debug build (works on physical devices; project `minSdk=31` already meets API requirement).
|
||||
- For many non-structural code/resource changes: use Android Studio **Apply Changes**.
|
||||
- For structural/native/manifest/Gradle changes: do full reinstall (`pnpm android:run`).
|
||||
- Canvas web content already supports live reload when loaded from Gateway `__openclaw__/canvas/` (see `docs/platforms/android.md`).
|
||||
|
||||
## Connect / Pair
|
||||
|
||||
1) Start the gateway (on your main machine):
|
||||
|
||||
@@ -2,7 +2,6 @@ import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
@@ -13,7 +12,7 @@ android {
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/OpenClawKit/Sources/OpenClawKit/Resources"))
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +20,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602230
|
||||
versionName = "2026.2.24"
|
||||
versionCode = 202602260
|
||||
versionName = "2026.2.26"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -97,7 +96,7 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
@@ -112,7 +111,7 @@ dependencies {
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
@@ -120,12 +119,17 @@ dependencies {
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
|
||||
implementation("org.commonmark:commonmark:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-autolink:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-tables:0.27.1")
|
||||
implementation("org.commonmark:commonmark-ext-task-list-items:0.27.1")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
@@ -133,15 +137,17 @@ dependencies {
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -50,6 +59,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package ai.openclaw.android
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -26,10 +22,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
||||
applyImmersiveMode()
|
||||
NodeForegroundService.start(this)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
@@ -57,18 +50,9 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
applyImmersiveMode()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
applyImmersiveMode()
|
||||
}
|
||||
// Keep startup path lean: start foreground service after first frame.
|
||||
window.decorView.post { NodeForegroundService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -80,12 +64,4 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ import ai.openclaw.android.node.CameraCaptureManager
|
||||
import ai.openclaw.android.node.CanvasController
|
||||
import ai.openclaw.android.node.ScreenRecordManager
|
||||
import ai.openclaw.android.node.SmsManager
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||
|
||||
val canvas: CanvasController = runtime.canvas
|
||||
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
|
||||
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
|
||||
val camera: CameraCaptureManager = runtime.camera
|
||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||
val sms: SmsManager = runtime.sms
|
||||
@@ -22,6 +27,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||
|
||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
@@ -40,14 +46,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
||||
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
|
||||
val micStatusText: StateFlow<String> = runtime.micStatusText
|
||||
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
|
||||
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
|
||||
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
@@ -123,24 +129,8 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
runtime.resetWakeWordsDefaults()
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
runtime.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(enabled: Boolean) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun logGatewayDebugSnapshot(source: String = "manual") {
|
||||
runtime.logGatewayDebugSnapshot(source)
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
@@ -171,6 +161,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
||||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
||||
@@ -2,23 +2,12 @@ package ai.openclaw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import java.security.Security
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Register Bouncy Castle as highest-priority provider for Ed25519 support
|
||||
try {
|
||||
val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
|
||||
.getDeclaredConstructor().newInstance() as java.security.Provider
|
||||
Security.removeProvider("BC")
|
||||
Security.insertProviderAt(bcProvider, 1)
|
||||
} catch (it: Throwable) {
|
||||
Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
||||
@@ -39,22 +39,22 @@ class NodeForegroundService : Service() {
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.voiceWakeMode,
|
||||
runtime.voiceWakeIsListening,
|
||||
) { status, server, connected, voiceMode, voiceListening ->
|
||||
Quint(status, server, connected, voiceMode, voiceListening)
|
||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||
runtime.micEnabled,
|
||||
runtime.micIsListening,
|
||||
) { status, server, connected, micEnabled, micListening ->
|
||||
Quint(status, server, connected, micEnabled, micListening)
|
||||
}.collect { (status, server, connected, micEnabled, micListening) ->
|
||||
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
|
||||
val voiceSuffix =
|
||||
if (voiceMode == VoiceWakeMode.Always) {
|
||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||
val micSuffix =
|
||||
if (micEnabled) {
|
||||
if (micListening) " · Mic: Listening" else " · Mic: Pending"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
|
||||
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
|
||||
|
||||
val requiresMic =
|
||||
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
|
||||
micEnabled && hasRecordAudioPermission()
|
||||
startForegroundWithTypes(
|
||||
notification = buildNotification(title = title, text = text),
|
||||
requiresMic = requiresMic,
|
||||
|
||||
@@ -19,8 +19,8 @@ import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
import ai.openclaw.android.voice.VoiceWakeManager
|
||||
import ai.openclaw.android.voice.MicCaptureManager
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -37,6 +37,7 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(context: Context) {
|
||||
@@ -54,40 +55,6 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
|
||||
private val voiceWake: VoiceWakeManager by lazy {
|
||||
VoiceWakeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
onCommand = { command ->
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(command))
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val voiceWakeIsListening: StateFlow<Boolean>
|
||||
get() = voiceWake.isListening
|
||||
|
||||
val voiceWakeStatusText: StateFlow<String>
|
||||
get() = voiceWake.statusText
|
||||
|
||||
val talkStatusText: StateFlow<String>
|
||||
get() = talkMode.statusText
|
||||
|
||||
val talkIsListening: StateFlow<Boolean>
|
||||
get() = talkMode.isListening
|
||||
|
||||
val talkIsSpeaking: StateFlow<Boolean>
|
||||
get() = talkMode.isSpeaking
|
||||
|
||||
private val discovery = GatewayDiscovery(appContext, scope = scope)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
@@ -125,6 +92,14 @@ class NodeRuntime(context: Context) {
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -146,7 +121,7 @@ class NodeRuntime(context: Context) {
|
||||
prefs = prefs,
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { voiceWakeMode.value },
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
@@ -156,6 +131,8 @@ class NodeRuntime(context: Context) {
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -164,10 +141,16 @@ class NodeRuntime(context: Context) {
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
)
|
||||
|
||||
private lateinit var gatewayEventHandler: GatewayEventHandler
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
@@ -175,6 +158,8 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
private val _nodeConnected = MutableStateFlow(false)
|
||||
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
@@ -195,6 +180,13 @@ class NodeRuntime(context: Context) {
|
||||
private val _screenRecordActive = MutableStateFlow(false)
|
||||
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
|
||||
|
||||
private val _canvasA2uiHydrated = MutableStateFlow(false)
|
||||
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
|
||||
private val _canvasRehydratePending = MutableStateFlow(false)
|
||||
val canvasRehydratePending: StateFlow<Boolean> = _canvasRehydratePending.asStateFlow()
|
||||
private val _canvasRehydrateErrorText = MutableStateFlow<String?>(null)
|
||||
val canvasRehydrateErrorText: StateFlow<String?> = _canvasRehydrateErrorText.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
@@ -208,8 +200,9 @@ class NodeRuntime(context: Context) {
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
private var didAutoRequestCanvasRehydrate = false
|
||||
private val canvasRehydrateSeq = AtomicLong(0)
|
||||
private var operatorConnected = false
|
||||
private var nodeConnected = false
|
||||
private var operatorStatusText: String = "Offline"
|
||||
private var nodeStatusText: String = "Offline"
|
||||
|
||||
@@ -226,8 +219,8 @@ class NodeRuntime(context: Context) {
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
@@ -238,11 +231,10 @@ class NodeRuntime(context: Context) {
|
||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||
_mainSessionKey.value = "main"
|
||||
}
|
||||
val mainKey = resolveMainSessionKey()
|
||||
talkMode.setMainSessionKey(mainKey)
|
||||
chat.applyMainSessionKey(mainKey)
|
||||
chat.applyMainSessionKey(resolveMainSessionKey())
|
||||
chat.onDisconnected(message)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(false)
|
||||
},
|
||||
onEvent = { event, payloadJson ->
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
@@ -255,14 +247,22 @@ class NodeRuntime(context: Context) {
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
nodeConnected = true
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
nodeConnected = false
|
||||
_nodeConnected.value = false
|
||||
nodeStatusText = message
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
@@ -282,34 +282,74 @@ class NodeRuntime(context: Context) {
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
)
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(
|
||||
private val micCapture: MicCaptureManager by lazy {
|
||||
MicCaptureManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
sendToGateway = { message ->
|
||||
val idempotencyKey = UUID.randomUUID().toString()
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
put("message", JsonPrimitive(message))
|
||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(idempotencyKey))
|
||||
}
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val micStatusText: StateFlow<String>
|
||||
get() = micCapture.statusText
|
||||
|
||||
val micLiveTranscript: StateFlow<String?>
|
||||
get() = micCapture.liveTranscript
|
||||
|
||||
val micIsListening: StateFlow<Boolean>
|
||||
get() = micCapture.isListening
|
||||
|
||||
val micEnabled: StateFlow<Boolean>
|
||||
get() = micCapture.micEnabled
|
||||
|
||||
val micQueuedMessages: StateFlow<List<String>>
|
||||
get() = micCapture.queuedMessages
|
||||
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>>
|
||||
get() = micCapture.conversation
|
||||
|
||||
val micInputLevel: StateFlow<Float>
|
||||
get() = micCapture.inputLevel
|
||||
|
||||
val micIsSending: StateFlow<Boolean>
|
||||
get() = micCapture.isSending
|
||||
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = normalizeMainKey(candidate) ?: return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
val operator = operatorStatusText.trim()
|
||||
val node = nodeStatusText.trim()
|
||||
_statusText.value =
|
||||
when {
|
||||
operatorConnected && nodeConnected -> "Connected"
|
||||
operatorConnected && !nodeConnected -> "Connected (node offline)"
|
||||
!operatorConnected && nodeConnected -> "Connected (operator offline)"
|
||||
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
|
||||
else -> nodeStatusText
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,18 +369,69 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
|
||||
scope.launch {
|
||||
if (!_nodeConnected.value) {
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "Node offline. Reconnect and retry."
|
||||
return@launch
|
||||
}
|
||||
if (!force && didAutoRequestCanvasRehydrate) return@launch
|
||||
didAutoRequestCanvasRehydrate = true
|
||||
val requestId = canvasRehydrateSeq.incrementAndGet()
|
||||
_canvasRehydratePending.value = true
|
||||
_canvasRehydrateErrorText.value = null
|
||||
|
||||
val sessionKey = resolveMainSessionKey()
|
||||
val prompt =
|
||||
"Restore canvas now for session=$sessionKey source=$source. " +
|
||||
"If existing A2UI state exists, replay it immediately. " +
|
||||
"If not, create and render a compact mobile-friendly dashboard in Canvas."
|
||||
val sent =
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
buildJsonObject {
|
||||
put("message", JsonPrimitive(prompt))
|
||||
put("sessionKey", JsonPrimitive(sessionKey))
|
||||
put("thinking", JsonPrimitive("low"))
|
||||
put("deliver", JsonPrimitive(false))
|
||||
}.toString(),
|
||||
)
|
||||
if (!sent) {
|
||||
if (!force) {
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
}
|
||||
if (canvasRehydrateSeq.get() == requestId) {
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "Failed to request restore. Tap to retry."
|
||||
}
|
||||
Log.w("OpenClawCanvas", "canvas rehydrate request failed ($source): transport unavailable")
|
||||
return@launch
|
||||
}
|
||||
scope.launch {
|
||||
delay(20_000)
|
||||
if (canvasRehydrateSeq.get() != requestId) return@launch
|
||||
if (!_canvasRehydratePending.value) return@launch
|
||||
if (_canvasA2uiHydrated.value) return@launch
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = "No canvas update yet. Tap to retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val instanceId: StateFlow<String> = prefs.instanceId
|
||||
val displayName: StateFlow<String> = prefs.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
@@ -367,50 +458,17 @@ class NodeRuntime(context: Context) {
|
||||
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||
|
||||
init {
|
||||
gatewayEventHandler = GatewayEventHandler(
|
||||
scope = scope,
|
||||
prefs = prefs,
|
||||
json = json,
|
||||
operatorSession = operatorSession,
|
||||
isConnected = { _isConnected.value },
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
voiceWakeMode,
|
||||
isForeground,
|
||||
externalAudioCaptureActive,
|
||||
wakeWords,
|
||||
) { mode, foreground, externalAudio, words ->
|
||||
Quad(mode, foreground, externalAudio, words)
|
||||
}.distinctUntilChanged()
|
||||
.collect { (mode, foreground, externalAudio, words) ->
|
||||
voiceWake.setTriggerWords(words)
|
||||
|
||||
val shouldListen =
|
||||
when (mode) {
|
||||
VoiceWakeMode.Off -> false
|
||||
VoiceWakeMode.Foreground -> foreground
|
||||
VoiceWakeMode.Always -> true
|
||||
} && !externalAudio
|
||||
|
||||
if (!shouldListen) {
|
||||
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
|
||||
return@collect
|
||||
}
|
||||
|
||||
if (!hasRecordAudioPermission()) {
|
||||
voiceWake.stop(statusText = "Microphone permission required")
|
||||
return@collect
|
||||
}
|
||||
|
||||
voiceWake.start()
|
||||
}
|
||||
if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) {
|
||||
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
talkEnabled.collect { enabled ->
|
||||
talkMode.setEnabled(enabled)
|
||||
prefs.loadGatewayToken()
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
micCapture.setMicEnabled(enabled)
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
@@ -518,34 +576,20 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
setWakeWords(SecurePrefs.defaultWakeWords)
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
fun setMicEnabled(value: Boolean) {
|
||||
prefs.setTalkEnabled(value)
|
||||
}
|
||||
|
||||
fun logGatewayDebugSnapshot(source: String = "manual") {
|
||||
val flowToken = gatewayToken.value.trim()
|
||||
val loadedToken = prefs.loadGatewayToken().orEmpty()
|
||||
Log.i(
|
||||
"OpenClawGatewayDebug",
|
||||
"source=$source manualEnabled=${manualEnabled.value} host=${manualHost.value} port=${manualPort.value} tls=${manualTls.value} flowTokenLen=${flowToken.length} loadTokenLen=${loadedToken.length} connected=${isConnected.value} status=${statusText.value}",
|
||||
)
|
||||
micCapture.setMicEnabled(value)
|
||||
externalAudioCaptureActive.value = value
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
_statusText.value = "Failed: no cached gateway endpoint"
|
||||
return
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
@@ -652,10 +696,10 @@ class NodeRuntime(context: Context) {
|
||||
contextJson = contextJson,
|
||||
)
|
||||
|
||||
val connected = nodeConnected
|
||||
val connected = _nodeConnected.value
|
||||
var error: String? = null
|
||||
if (connected) {
|
||||
try {
|
||||
val sent =
|
||||
nodeSession.sendNodeEvent(
|
||||
event = "agent.request",
|
||||
payloadJson =
|
||||
@@ -667,8 +711,8 @@ class NodeRuntime(context: Context) {
|
||||
put("key", JsonPrimitive(actionId))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
error = e.message ?: "send failed"
|
||||
if (!sent) {
|
||||
error = "send failed"
|
||||
}
|
||||
} else {
|
||||
error = "gateway not connected"
|
||||
@@ -718,15 +762,19 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
if (event == "voicewake.changed") {
|
||||
gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun parseChatSendRunId(response: String): String? {
|
||||
return try {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
|
||||
root["runId"].asStringOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
|
||||
@@ -20,19 +20,21 @@ class SecurePrefs(context: Context) {
|
||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
private const val plainPrefsName = "openclaw.node"
|
||||
private const val securePrefsName = "openclaw.node.secure"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
|
||||
private val masterKey =
|
||||
MasterKey.Builder(context)
|
||||
private val masterKey by lazy {
|
||||
MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
createPrefs(appContext, "openclaw.node.secure")
|
||||
}
|
||||
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
|
||||
|
||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
@@ -41,52 +43,51 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost =
|
||||
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
|
||||
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort =
|
||||
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
|
||||
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _manualTls =
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _gatewayToken =
|
||||
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
|
||||
private val _gatewayToken = MutableStateFlow("")
|
||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||
|
||||
private val _onboardingCompleted =
|
||||
MutableStateFlow(prefs.getBoolean("onboarding.completed", false))
|
||||
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
@@ -95,65 +96,65 @@ class SecurePrefs(context: Context) {
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
plainPrefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
plainPrefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("gateway.manual.port", value) }
|
||||
plainPrefs.edit { putInt("gateway.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) }
|
||||
securePrefs.edit { putString("gateway.manual.token", trimmed) }
|
||||
_gatewayToken.value = trimmed
|
||||
}
|
||||
|
||||
@@ -162,62 +163,67 @@ class SecurePrefs(context: Context) {
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
prefs.edit { putBoolean("onboarding.completed", value) }
|
||||
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
||||
_onboardingCompleted.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual = _gatewayToken.value.trim()
|
||||
val manual =
|
||||
_gatewayToken.value.trim().ifEmpty {
|
||||
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
|
||||
if (stored.isNotEmpty()) _gatewayToken.value = stored
|
||||
stored
|
||||
}
|
||||
if (manual.isNotEmpty()) return manual
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
prefs.edit { putString(key, password.trim()) }
|
||||
securePrefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "gateway.tls.$stableId"
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
plainPrefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
return securePrefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
prefs.edit { putString(key, value) }
|
||||
securePrefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs.edit { remove(key) }
|
||||
securePrefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun createPrefs(context: Context, name: String): SharedPreferences {
|
||||
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
name,
|
||||
@@ -228,21 +234,21 @@ class SecurePrefs(context: Context) {
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
plainPrefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
plainPrefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -250,34 +256,34 @@ class SecurePrefs(context: Context) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("talk.enabled", value) }
|
||||
plainPrefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
@@ -295,5 +301,4 @@ class SecurePrefs(context: Context) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -261,11 +261,7 @@ class ChatController(
|
||||
val key = _sessionKey.value
|
||||
try {
|
||||
if (supportsChatSubscribe) {
|
||||
try {
|
||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
} catch (_: Throwable) {
|
||||
// best-effort
|
||||
}
|
||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
}
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
@@ -325,6 +321,12 @@ class ChatController(
|
||||
|
||||
val state = payload["state"].asStringOrNull()
|
||||
when (state) {
|
||||
"delta" -> {
|
||||
val text = parseAssistantDeltaText(payload)
|
||||
if (!text.isNullOrEmpty()) {
|
||||
_streamingAssistantText.value = text
|
||||
}
|
||||
}
|
||||
"final", "aborted", "error" -> {
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
@@ -351,9 +353,8 @@ class ChatController(
|
||||
|
||||
private fun handleAgentEvent(payloadJson: String) {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
val sessionId = _sessionId.value
|
||||
if (sessionId != null && runId != sessionId) return
|
||||
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
|
||||
|
||||
val stream = payload["stream"].asStringOrNull()
|
||||
val data = payload["data"].asObjectOrNull()
|
||||
@@ -398,6 +399,21 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAssistantDeltaText(payload: JsonObject): String? {
|
||||
val message = payload["message"].asObjectOrNull() ?: return null
|
||||
if (message["role"].asStringOrNull() != "assistant") return null
|
||||
val content = message["content"].asArrayOrNull() ?: return null
|
||||
for (item in content) {
|
||||
val obj = item.asObjectOrNull() ?: continue
|
||||
if (obj["type"].asStringOrNull() != "text") continue
|
||||
val text = obj["text"].asStringOrNull()
|
||||
if (!text.isNullOrEmpty()) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun publishPendingToolCalls() {
|
||||
_pendingToolCalls.value =
|
||||
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
internal object DeviceAuthPayload {
|
||||
fun buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
platform: String?,
|
||||
deviceFamily: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val platformNorm = normalizeMetadataField(platform)
|
||||
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
|
||||
return listOf(
|
||||
"v3",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
platformNorm,
|
||||
deviceFamilyNorm,
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
internal fun normalizeMetadataField(value: String?): String {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
|
||||
// lowercase ASCII A-Z only for auth payload metadata fields.
|
||||
val out = StringBuilder(trimmed.length)
|
||||
for (ch in trimmed) {
|
||||
if (ch in 'A'..'Z') {
|
||||
out.append((ch.code + 32).toChar())
|
||||
} else {
|
||||
out.append(ch)
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@ package ai.openclaw.android.gateway
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ package ai.openclaw.android.gateway
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -22,21 +18,26 @@ data class DeviceIdentity(
|
||||
class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
cachedIdentity?.let { return it }
|
||||
val existing = load()
|
||||
if (existing != null) {
|
||||
val derived = deriveDeviceId(existing.publicKeyRawBase64)
|
||||
if (derived != null && derived != existing.deviceId) {
|
||||
val updated = existing.copy(deviceId = derived)
|
||||
save(updated)
|
||||
cachedIdentity = updated
|
||||
return updated
|
||||
}
|
||||
cachedIdentity = existing
|
||||
return existing
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -151,22 +152,16 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
|
||||
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
|
||||
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
|
||||
) {
|
||||
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
|
||||
}
|
||||
return spki
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
val out = CharArray(digest.size * 2)
|
||||
var i = 0
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
val v = byte.toInt() and 0xff
|
||||
out[i++] = HEX[v ushr 4]
|
||||
out[i++] = HEX[v and 0x0f]
|
||||
}
|
||||
return out.toString()
|
||||
return String(out)
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(data: ByteArray): String {
|
||||
@@ -174,9 +169,6 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ED25519_SPKI_PREFIX =
|
||||
byteArrayOf(
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
)
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,13 +55,18 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
) {
|
||||
private companion object {
|
||||
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
|
||||
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
|
||||
}
|
||||
|
||||
data class InvokeRequest(
|
||||
val id: String,
|
||||
val nodeId: String,
|
||||
@@ -131,8 +136,8 @@ class GatewaySession(
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
|
||||
val conn = currentConnection ?: return
|
||||
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
|
||||
val conn = currentConnection ?: return false
|
||||
val parsedPayload = payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
@@ -147,8 +152,10 @@ class GatewaySession(
|
||||
}
|
||||
try {
|
||||
conn.request("node.event", params, timeoutMs = 8_000)
|
||||
return true
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,9 +200,7 @@ class GatewaySession(
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val httpScheme = if (tls != null) "https" else "http"
|
||||
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).header("Origin", origin).build()
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
@@ -300,26 +305,13 @@ class GatewaySession(
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().orEmpty()
|
||||
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
|
||||
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
|
||||
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
|
||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
||||
var res = request("connect", payload, timeoutMs = 8_000)
|
||||
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
|
||||
if (!res.ok) {
|
||||
val msg = res.error?.message ?: "connect failed"
|
||||
val hasStoredToken = !storedToken.isNullOrBlank()
|
||||
val canRetryWithShared = hasStoredToken && trimmedToken.isNotBlank()
|
||||
if (canRetryWithShared) {
|
||||
val sharedPayload = buildConnectParams(identity, connectNonce, trimmedToken, password?.trim())
|
||||
val sharedRes = request("connect", sharedPayload, timeoutMs = 8_000)
|
||||
if (!sharedRes.ok) {
|
||||
val retryMsg = sharedRes.error?.message ?: msg
|
||||
throw IllegalStateException(retryMsg)
|
||||
}
|
||||
// Stored device token was bypassed successfully; clear stale token for future connects.
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
res = sharedRes
|
||||
} else {
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
handleConnectSuccess(res, identity.deviceId)
|
||||
connectDeferred.complete(Unit)
|
||||
@@ -336,7 +328,7 @@ class GatewaySession(
|
||||
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
||||
val sessionDefaults =
|
||||
obj["snapshot"].asObjectOrNull()
|
||||
?.get("sessionDefaults").asObjectOrNull()
|
||||
@@ -380,7 +372,7 @@ class GatewaySession(
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
@@ -389,6 +381,8 @@ class GatewaySession(
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
platform = client.platform,
|
||||
deviceFamily = client.deviceFamily,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
@@ -541,16 +535,8 @@ class GatewaySession(
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
|
||||
return InvokeResult.error(code = parsed.code, message = parsed.message)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
@@ -598,51 +584,30 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDeviceAuthPayload(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val parts =
|
||||
mutableListOf(
|
||||
"v2",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||
private fun normalizeCanvasHostUrl(
|
||||
raw: String?,
|
||||
endpoint: GatewayEndpoint,
|
||||
isTlsConnection: Boolean,
|
||||
): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
val suffix = buildUrlSuffix(parsed)
|
||||
|
||||
// Detect TLS reverse proxy: endpoint on port 443, or domain-based host
|
||||
val tls = endpoint.port == 443 || endpoint.host.contains(".")
|
||||
|
||||
// If raw URL is a non-loopback address AND we're behind TLS reverse proxy,
|
||||
// fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy)
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
if (tls && port > 0 && port != 443) {
|
||||
// Rewrite the URL to use the reverse proxy port instead of the raw gateway port
|
||||
val fixedScheme = "https"
|
||||
val formattedHost = if (host.contains(":")) "[${host}]" else host
|
||||
return "$fixedScheme://$formattedHost"
|
||||
// If raw URL is a non-loopback address and this connection uses TLS,
|
||||
// normalize scheme/port to the endpoint we actually connected to.
|
||||
if (trimmed.isNotBlank() && host.isNotBlank() && !isLoopbackHost(host)) {
|
||||
val needsTlsRewrite =
|
||||
isTlsConnection &&
|
||||
(
|
||||
!scheme.equals("https", ignoreCase = true) ||
|
||||
(port > 0 && port != endpoint.port) ||
|
||||
(port <= 0 && endpoint.port != 443)
|
||||
)
|
||||
if (needsTlsRewrite) {
|
||||
return buildCanvasUrl(host = host, scheme = "https", port = endpoint.port, suffix = suffix)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -653,14 +618,26 @@ class GatewaySession(
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
// When connecting through a reverse proxy (TLS on standard port), use the
|
||||
// connection endpoint's scheme and port instead of the raw canvas port.
|
||||
val fallbackScheme = if (tls) "https" else scheme
|
||||
// Behind reverse proxy, always use the proxy port (443), not the raw canvas port
|
||||
val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort"
|
||||
return "$fallbackScheme://$formattedHost$portSuffix"
|
||||
// For TLS connections, use the connected endpoint's scheme/port instead of raw canvas metadata.
|
||||
val fallbackScheme = if (isTlsConnection) "https" else scheme
|
||||
// For TLS, always use the connected endpoint port.
|
||||
val fallbackPort = if (isTlsConnection) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
|
||||
return buildCanvasUrl(host = fallbackHost, scheme = fallbackScheme, port = fallbackPort, suffix = suffix)
|
||||
}
|
||||
|
||||
private fun buildCanvasUrl(host: String, scheme: String, port: Int, suffix: String): String {
|
||||
val loweredScheme = scheme.lowercase()
|
||||
val formattedHost = if (host.contains(":")) "[${host}]" else host
|
||||
val portSuffix = if ((loweredScheme == "https" && port == 443) || (loweredScheme == "http" && port == 80)) "" else ":$port"
|
||||
return "$loweredScheme://$formattedHost$portSuffix$suffix"
|
||||
}
|
||||
|
||||
private fun buildUrlSuffix(uri: java.net.URI?): String {
|
||||
if (uri == null) return ""
|
||||
val path = uri.rawPath?.takeIf { it.isNotBlank() } ?: ""
|
||||
val query = uri.rawQuery?.takeIf { it.isNotBlank() }?.let { "?$it" } ?: ""
|
||||
val fragment = uri.rawFragment?.takeIf { it.isNotBlank() }?.let { "#$it" } ?: ""
|
||||
return "$path$query$fragment"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
data class ParsedInvokeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
|
||||
}
|
||||
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return ParsedInvokeError(
|
||||
code = code,
|
||||
message = rest.ifEmpty { trimmed },
|
||||
hadExplicitCode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
): ParsedInvokeError {
|
||||
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
|
||||
return parseInvokeErrorMessage(raw)
|
||||
}
|
||||
@@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 800
|
||||
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.core.graphics.scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
@@ -31,6 +34,8 @@ class CanvasController {
|
||||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
private val _currentUrl = MutableStateFlow<String?>(null)
|
||||
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
|
||||
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
@@ -45,9 +50,16 @@ class CanvasController {
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun detach(webView: WebView) {
|
||||
if (this.webView === webView) {
|
||||
this.webView = null
|
||||
}
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
val trimmed = url.trim()
|
||||
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||
_currentUrl.value = this.url
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
@@ -80,37 +74,18 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled()) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
}
|
||||
add("app.update")
|
||||
}
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.Device.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
) {
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val batteryStatus =
|
||||
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val batteryLevel = batteryLevelFraction(batteryIntent)
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val storage = StatFs(Environment.getDataDirectory().absolutePath)
|
||||
val totalBytes = storage.totalBytes
|
||||
val freeBytes = storage.availableBytes
|
||||
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
|
||||
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
|
||||
val activeNetwork = connectivity?.activeNetwork
|
||||
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
|
||||
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
|
||||
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
batteryLevel?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"thermal",
|
||||
buildJsonObject {
|
||||
put("state", JsonPrimitive(mapThermalState(powerManager)))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"storage",
|
||||
buildJsonObject {
|
||||
put("totalBytes", JsonPrimitive(totalBytes))
|
||||
put("freeBytes", JsonPrimitive(freeBytes))
|
||||
put("usedBytes", JsonPrimitive(usedBytes))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"network",
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive(mapNetworkStatus(caps)))
|
||||
put(
|
||||
"isExpensive",
|
||||
JsonPrimitive(
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"isConstrained",
|
||||
JsonPrimitive(
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false,
|
||||
),
|
||||
)
|
||||
put("interfaces", networkInterfacesJson(caps))
|
||||
},
|
||||
)
|
||||
put("uptimeSeconds", JsonPrimitive(uptimeSeconds))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun infoPayloadJson(): String {
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
|
||||
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
|
||||
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val locale = Locale.getDefault().toLanguageTag().trim()
|
||||
val appVersion = BuildConfig.VERSION_NAME.trim()
|
||||
val appBuild = BuildConfig.VERSION_CODE.toString()
|
||||
|
||||
return buildJsonObject {
|
||||
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
|
||||
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
|
||||
put("systemName", JsonPrimitive("Android"))
|
||||
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
|
||||
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
|
||||
put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" }))
|
||||
put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() }))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun batteryLevelFraction(intent: Intent?): Double? {
|
||||
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
if (rawLevel < 0 || rawScale <= 0) return null
|
||||
return rawLevel.toDouble() / rawScale.toDouble()
|
||||
}
|
||||
|
||||
private fun mapBatteryState(status: Int): String {
|
||||
return when (status) {
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
|
||||
BatteryManager.BATTERY_STATUS_FULL -> "full"
|
||||
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapThermalState(powerManager: PowerManager?): String {
|
||||
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
|
||||
return when (thermal) {
|
||||
PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal"
|
||||
PowerManager.THERMAL_STATUS_MODERATE -> "fair"
|
||||
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
|
||||
PowerManager.THERMAL_STATUS_CRITICAL,
|
||||
PowerManager.THERMAL_STATUS_EMERGENCY,
|
||||
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
|
||||
else -> "nominal"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapNetworkStatus(caps: NetworkCapabilities?): String {
|
||||
if (caps == null) return "unsatisfied"
|
||||
return when {
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
|
||||
else -> "unsatisfied"
|
||||
}
|
||||
}
|
||||
|
||||
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
|
||||
buildJsonArray {
|
||||
if (caps == null) return@buildJsonArray
|
||||
var hasKnownTransport = false
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("wifi"))
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("cellular"))
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("wired"))
|
||||
}
|
||||
if (!hasKnownTransport) add(JsonPrimitive("other"))
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
|
||||
}
|
||||
|
||||
data class DeviceNotificationEntry(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val subText: String?,
|
||||
val category: String?,
|
||||
val channelId: String?,
|
||||
val postTimeMs: Long,
|
||||
val isOngoing: Boolean,
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
|
||||
|
||||
fun replace(entries: List<DeviceNotificationEntry>) {
|
||||
synchronized(lock) {
|
||||
byKey.clear()
|
||||
for (entry in entries) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsert(entry: DeviceNotificationEntry) {
|
||||
synchronized(lock) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
synchronized(lock) {
|
||||
byKey.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun setConnected(value: Boolean) {
|
||||
synchronized(lock) {
|
||||
connected = value
|
||||
if (!value) {
|
||||
byKey.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
|
||||
val (isConnected, entries) =
|
||||
synchronized(lock) {
|
||||
connected to byKey.values.sortedByDescending { it.postTimeMs }
|
||||
}
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = enabled,
|
||||
connected = isConnected,
|
||||
notifications = entries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
DeviceNotificationStore.remove(key)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
val entries =
|
||||
runCatching {
|
||||
activeNotifications
|
||||
?.mapNotNull { it.toEntry() }
|
||||
?: emptyList()
|
||||
}.getOrElse { emptyList() }
|
||||
DeviceNotificationStore.replace(entries)
|
||||
}
|
||||
|
||||
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
|
||||
val extras = notification.extras
|
||||
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
|
||||
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
|
||||
val body =
|
||||
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
|
||||
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
|
||||
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
|
||||
return DeviceNotificationEntry(
|
||||
key = keyValue,
|
||||
packageName = packageName,
|
||||
title = title,
|
||||
text = body,
|
||||
subText = subText,
|
||||
category = notification.category?.trim()?.ifEmpty { null },
|
||||
channelId = notification.channelId?.trim()?.ifEmpty { null },
|
||||
postTimeMs = postTime,
|
||||
isOngoing = isOngoing,
|
||||
isClearable = isClearable,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
}
|
||||
|
||||
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
|
||||
return DeviceNotificationStore.snapshot(enabled = enabled)
|
||||
}
|
||||
|
||||
fun requestServiceRebind(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Present.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Hide.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Navigate.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Eval.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Snapshot.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Push.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Reset.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Clip.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawLocationCommand.Get.rawValue,
|
||||
availability = InvokeCommandAvailability.LocationEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Status.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.ed25519",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(name = "app.update"),
|
||||
)
|
||||
|
||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
@@ -12,6 +14,8 @@ class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
private val locationHandler: LocationHandler,
|
||||
private val deviceHandler: DeviceHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -20,38 +24,25 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
// Check foreground requirement for canvas/camera/screen commands
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
val spec =
|
||||
InvokeCommandRegistry.find(command)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check camera enabled
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
|
||||
if (spec.requiresForeground && !isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
// Check location enabled
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
availabilityError(spec.availability)?.let { return it }
|
||||
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
@@ -73,52 +64,33 @@ class InvokeDispatcher(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
withCanvasAvailable {
|
||||
val result = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
withCanvasAvailable {
|
||||
val base64 =
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
}
|
||||
|
||||
// A2UI commands
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue ->
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
@@ -129,21 +101,14 @@ class InvokeDispatcher(
|
||||
message = err.message ?: "invalid A2UI payload"
|
||||
)
|
||||
}
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
@@ -153,6 +118,13 @@ class InvokeDispatcher(
|
||||
// Location command
|
||||
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
|
||||
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
|
||||
@@ -166,11 +138,80 @@ class InvokeDispatcher(
|
||||
// App update
|
||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
||||
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
private suspend fun withCanvasAvailable(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
|
||||
return when (availability) {
|
||||
InvokeCommandAvailability.Always -> null
|
||||
InvokeCommandAvailability.CameraEnabled ->
|
||||
if (cameraEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.LocationEnabled ->
|
||||
if (locationEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -37,14 +38,9 @@ fun parseHexColorArgb(raw: String?): Long? {
|
||||
}
|
||||
|
||||
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
return code to "$code: $message"
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
|
||||
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
|
||||
return parsed.code to message
|
||||
}
|
||||
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
|
||||
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
if (!enabled) {
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
)
|
||||
}
|
||||
return DeviceNotificationListenerService.snapshot(context, enabled = true)
|
||||
}
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val stateProvider: NotificationsStateProvider,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
return buildJsonObject {
|
||||
put("enabled", JsonPrimitive(snapshot.enabled))
|
||||
put("connected", JsonPrimitive(snapshot.connected))
|
||||
put("count", JsonPrimitive(snapshot.notifications.size))
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
stateProvider: NotificationsStateProvider,
|
||||
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
|
||||
}
|
||||
}
|
||||
+20
@@ -7,6 +7,7 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Device("device"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -69,3 +70,22 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
Status("device.status"),
|
||||
Info("device.info"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "device."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -25,6 +28,18 @@ import ai.openclaw.android.MainViewModel
|
||||
fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
val webViewRef = remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
DisposableEffect(viewModel) {
|
||||
onDispose {
|
||||
val webView = webViewRef.value ?: return@onDispose
|
||||
viewModel.canvas.detach(webView)
|
||||
webView.removeJavascriptInterface(CanvasA2UIActionBridge.interfaceName)
|
||||
webView.stopLoading()
|
||||
webView.destroy()
|
||||
webViewRef.value = null
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
@@ -33,6 +48,11 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
settings.useWideViewPort = false
|
||||
settings.loadWithOverviewMode = false
|
||||
settings.builtInZoomControls = false
|
||||
settings.displayZoomControls = false
|
||||
settings.setSupportZoom(false)
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
} else {
|
||||
@@ -104,6 +124,7 @@ fun CanvasScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
val bridge = CanvasA2UIActionBridge { payload -> viewModel.handleCanvasA2UIActionFromWebView(payload) }
|
||||
addJavascriptInterface(bridge, CanvasA2UIActionBridge.interfaceName)
|
||||
viewModel.canvas.attach(this)
|
||||
webViewRef.value = this
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -168,6 +168,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
validationText = null
|
||||
return@Button
|
||||
}
|
||||
if (statusText.contains("operator offline", ignoreCase = true)) {
|
||||
validationText = null
|
||||
viewModel.refreshGatewayConnection()
|
||||
return@Button
|
||||
}
|
||||
|
||||
val config =
|
||||
resolveGatewayConnectConfig(
|
||||
@@ -397,15 +402,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
|
||||
Text(
|
||||
"Debug snapshot: mode=${if (inputMode == ConnectInputMode.SetupCode) "setup" else "manual"}, manualEnabled=$manualEnabled, tokenLen=${gatewayToken.trim().length}",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
TextButton(onClick = { viewModel.logGatewayDebugSnapshot(source = "connect_tab") }) {
|
||||
Text("Log gateway debug snapshot", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
}
|
||||
|
||||
TextButton(onClick = { viewModel.setOnboardingCompleted(false) }) {
|
||||
Text("Run onboarding again", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), color = mobileAccent)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package ai.openclaw.android.ui
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.core.net.toUri
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
internal data class GatewayEndpointConfig(
|
||||
val host: String,
|
||||
@@ -26,6 +30,8 @@ internal data class GatewayConnectConfig(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun resolveGatewayConnectConfig(
|
||||
useSetupCode: Boolean,
|
||||
setupCode: String,
|
||||
@@ -94,18 +100,23 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
|
||||
return try {
|
||||
val decoded = String(Base64.decode(padded, Base64.DEFAULT), Charsets.UTF_8)
|
||||
val obj = JSONObject(decoded)
|
||||
val url = obj.optString("url").trim()
|
||||
val decoded = String(Base64.getDecoder().decode(padded), Charsets.UTF_8)
|
||||
val obj = parseJsonObject(decoded) ?: return null
|
||||
val url = jsonField(obj, "url").orEmpty()
|
||||
if (url.isEmpty()) return null
|
||||
val token = obj.optString("token").trim().ifEmpty { null }
|
||||
val password = obj.optString("password").trim().ifEmpty { null }
|
||||
val token = jsonField(obj, "token")
|
||||
val password = jsonField(obj, "password")
|
||||
GatewaySetupCode(url = url, token = token, password = password)
|
||||
} catch (_: Throwable) {
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? {
|
||||
val setupCode = resolveSetupCodeCandidate(rawInput) ?: return null
|
||||
return setupCode.takeIf { decodeGatewaySetupCode(it) != null }
|
||||
}
|
||||
|
||||
internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls: Boolean): String? {
|
||||
val host = hostInput.trim()
|
||||
val port = portInput.trim().toIntOrNull() ?: return null
|
||||
@@ -113,3 +124,19 @@ internal fun composeGatewayManualUrl(hostInput: String, portInput: String, tls:
|
||||
val scheme = if (tls) "https" else "http"
|
||||
return "$scheme://$host:$port"
|
||||
}
|
||||
|
||||
private fun parseJsonObject(input: String): JsonObject? {
|
||||
return runCatching { gatewaySetupJson.parseToJsonElement(input).jsonObject }.getOrNull()
|
||||
}
|
||||
|
||||
private fun resolveSetupCodeCandidate(rawInput: String): String? {
|
||||
val trimmed = rawInput.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val qrSetupCode = parseJsonObject(trimmed)?.let { jsonField(it, "setupCode") }
|
||||
return qrSetupCode ?: trimmed
|
||||
}
|
||||
|
||||
private fun jsonField(obj: JsonObject, key: String): String? {
|
||||
val value = (obj[key] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
|
||||
return value.ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -51,6 +52,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -74,6 +77,8 @@ import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.R
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
private enum class OnboardingStep(val index: Int, val label: String) {
|
||||
Welcome(1, "Welcome"),
|
||||
@@ -192,6 +197,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var gatewayUrl by rememberSaveable { mutableStateOf("") }
|
||||
var gatewayPassword by rememberSaveable { mutableStateOf("") }
|
||||
var gatewayInputMode by rememberSaveable { mutableStateOf(GatewayInputMode.SetupCode) }
|
||||
var gatewayAdvancedOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var manualHost by rememberSaveable { mutableStateOf("10.0.2.2") }
|
||||
var manualPort by rememberSaveable { mutableStateOf("18789") }
|
||||
var manualTls by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -246,6 +252,23 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
step = OnboardingStep.FinalCheck
|
||||
}
|
||||
|
||||
val qrScanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
val contents = result.contents?.trim().orEmpty()
|
||||
if (contents.isEmpty()) {
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
}
|
||||
|
||||
if (pendingTrust != null) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
@@ -316,6 +339,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
OnboardingStep.Gateway ->
|
||||
GatewayStep(
|
||||
inputMode = gatewayInputMode,
|
||||
advancedOpen = gatewayAdvancedOpen,
|
||||
setupCode = setupCode,
|
||||
manualHost = manualHost,
|
||||
manualPort = manualPort,
|
||||
@@ -323,6 +347,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
gatewayToken = persistedGatewayToken,
|
||||
gatewayPassword = gatewayPassword,
|
||||
gatewayError = gatewayError,
|
||||
onScanQrClick = {
|
||||
gatewayError = null
|
||||
qrScanLauncher.launch(
|
||||
ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("Scan OpenClaw onboarding QR")
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
},
|
||||
)
|
||||
},
|
||||
onAdvancedOpenChange = { gatewayAdvancedOpen = it },
|
||||
onInputModeChange = {
|
||||
gatewayInputMode = it
|
||||
gatewayError = null
|
||||
@@ -367,7 +403,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
remoteAddress = remoteAddress,
|
||||
attemptedConnect = attemptedConnect,
|
||||
enabledPermissions = enabledPermissionSummary,
|
||||
methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "Setup Code" else "Manual",
|
||||
methodLabel = if (gatewayInputMode == GatewayInputMode.SetupCode) "QR / Setup Code" else "Manual",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -429,7 +465,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
if (gatewayInputMode == GatewayInputMode.SetupCode) {
|
||||
val parsedSetup = decodeGatewaySetupCode(setupCode)
|
||||
if (parsedSetup == null) {
|
||||
gatewayError = "Invalid setup code."
|
||||
gatewayError = "Scan QR code first, or use Advanced setup."
|
||||
return@Button
|
||||
}
|
||||
val parsedGateway = parseGatewayEndpoint(parsedSetup.url)
|
||||
@@ -607,6 +643,7 @@ private fun WelcomeStep() {
|
||||
@Composable
|
||||
private fun GatewayStep(
|
||||
inputMode: GatewayInputMode,
|
||||
advancedOpen: Boolean,
|
||||
setupCode: String,
|
||||
manualHost: String,
|
||||
manualPort: String,
|
||||
@@ -614,6 +651,8 @@ private fun GatewayStep(
|
||||
gatewayToken: String,
|
||||
gatewayPassword: String,
|
||||
gatewayError: String?,
|
||||
onScanQrClick: () -> Unit,
|
||||
onAdvancedOpenChange: (Boolean) -> Unit,
|
||||
onInputModeChange: (GatewayInputMode) -> Unit,
|
||||
onSetupCodeChange: (String) -> Unit,
|
||||
onManualHostChange: (String) -> Unit,
|
||||
@@ -626,175 +665,225 @@ private fun GatewayStep(
|
||||
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
|
||||
|
||||
StepShell(title = "Gateway Connection") {
|
||||
GuideBlock(title = "Get setup code + gateway URL") {
|
||||
GuideBlock(title = "Scan onboarding QR") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw qr --setup-code-only")
|
||||
CommandBlock("openclaw qr --json")
|
||||
Text(
|
||||
"`--json` prints `setupCode` and `gatewayUrl`.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
CommandBlock("openclaw qr")
|
||||
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
Button(
|
||||
onClick = onScanQrClick,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
Text("QR captured. Review endpoint below.", style = onboardingCalloutStyle, color = onboardingSuccess)
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
}
|
||||
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
|
||||
|
||||
if (inputMode == GatewayInputMode.SetupCode) {
|
||||
Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = setupCode,
|
||||
onValueChange = onSetupCodeChange,
|
||||
placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
QuickFillChip(label = "Android Emulator", onClick = {
|
||||
onManualHostChange("10.0.2.2")
|
||||
onManualPortChange("18789")
|
||||
onManualTlsChange(false)
|
||||
})
|
||||
QuickFillChip(label = "Localhost", onClick = {
|
||||
onManualHostChange("127.0.0.1")
|
||||
onManualPortChange("18789")
|
||||
onManualTlsChange(false)
|
||||
})
|
||||
}
|
||||
|
||||
Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualHost,
|
||||
onValueChange = onManualHostChange,
|
||||
placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualPort,
|
||||
onValueChange = onManualPortChange,
|
||||
placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = onboardingSurface,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorderStrong),
|
||||
onClick = { onAdvancedOpenChange(!advancedOpen) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
Text("Advanced setup", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Paste setup code or enter host/port manually.", style = onboardingCaption1Style, color = onboardingTextSecondary)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTls,
|
||||
onCheckedChange = onManualTlsChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
Icon(
|
||||
imageVector = if (advancedOpen) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (advancedOpen) "Collapse advanced setup" else "Expand advanced setup",
|
||||
tint = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = onTokenChange,
|
||||
placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
AnimatedVisibility(visible = advancedOpen) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GuideBlock(title = "Manual setup commands") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw qr --setup-code-only")
|
||||
CommandBlock("openclaw qr --json")
|
||||
Text(
|
||||
"`--json` prints `setupCode` and `gatewayUrl`.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
|
||||
|
||||
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayPassword,
|
||||
onValueChange = onPasswordChange,
|
||||
placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
if (inputMode == GatewayInputMode.SetupCode) {
|
||||
Text("SETUP CODE", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = setupCode,
|
||||
onValueChange = onSetupCodeChange,
|
||||
placeholder = { Text("Paste code from `openclaw qr --setup-code-only`", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
QuickFillChip(label = "Android Emulator", onClick = {
|
||||
onManualHostChange("10.0.2.2")
|
||||
onManualPortChange("18789")
|
||||
onManualTlsChange(false)
|
||||
})
|
||||
QuickFillChip(label = "Localhost", onClick = {
|
||||
onManualHostChange("127.0.0.1")
|
||||
onManualPortChange("18789")
|
||||
onManualTlsChange(false)
|
||||
})
|
||||
}
|
||||
|
||||
if (!manualResolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = manualResolvedEndpoint)
|
||||
Text("HOST", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualHost,
|
||||
onValueChange = onManualHostChange,
|
||||
placeholder = { Text("10.0.2.2", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = manualPort,
|
||||
onValueChange = onManualPortChange,
|
||||
placeholder = { Text("18789", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Use TLS", style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text("Switch to secure websocket (`wss`).", style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
}
|
||||
Switch(
|
||||
checked = manualTls,
|
||||
onCheckedChange = onManualTlsChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Text("TOKEN (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = onTokenChange,
|
||||
placeholder = { Text("token", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
OutlinedTextField(
|
||||
value = gatewayPassword,
|
||||
onValueChange = onPasswordChange,
|
||||
placeholder = { Text("password", color = onboardingTextTertiary, style = onboardingBodyStyle) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
)
|
||||
|
||||
if (!manualResolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = manualResolvedEndpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,7 +1053,7 @@ private fun PermissionsStep(
|
||||
}
|
||||
PermissionToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Talk mode + voice features",
|
||||
subtitle = "Voice tab transcription",
|
||||
checked = enableMicrophone,
|
||||
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
|
||||
onCheckedChange = onMicrophoneChange,
|
||||
|
||||
@@ -5,20 +5,19 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
@@ -41,6 +40,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.android.MainViewModel
|
||||
@@ -65,10 +65,8 @@ private enum class StatusVisual {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||
val imeVisible = WindowInsets.isImeVisible
|
||||
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
@@ -85,6 +83,10 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val imeVisible = WindowInsets.ime.getBottom(density) > 0
|
||||
val hideBottomTabBar = activeTab == HomeTab.Chat && imeVisible
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
containerColor = Color.Transparent,
|
||||
@@ -96,7 +98,7 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (!imeVisible) {
|
||||
if (!hideBottomTabBar) {
|
||||
BottomTabBar(
|
||||
activeTab = activeTab,
|
||||
onSelect = { activeTab = it },
|
||||
@@ -109,19 +111,63 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(mobileBackgroundGradient),
|
||||
) {
|
||||
when (activeTab) {
|
||||
HomeTab.Connect -> ConnectTabScreen(viewModel = viewModel)
|
||||
HomeTab.Chat -> ChatSheet(viewModel = viewModel)
|
||||
HomeTab.Voice -> ComingSoonTabScreen(label = "VOICE", title = "Coming soon", description = "Voice mode is coming soon.")
|
||||
HomeTab.Screen -> ComingSoonTabScreen(label = "SCREEN", title = "Coming soon", description = "Screen mode is coming soon.")
|
||||
HomeTab.Voice -> VoiceTabScreen(viewModel = viewModel)
|
||||
HomeTab.Screen -> ScreenTabScreen(viewModel = viewModel)
|
||||
HomeTab.Settings -> SettingsSheet(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val canvasUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true
|
||||
val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated))
|
||||
val restoreCtaText =
|
||||
when {
|
||||
canvasRehydratePending -> "Restore requested. Waiting for agent…"
|
||||
!canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!!
|
||||
else -> "Canvas reset. Tap to restore dashboard."
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
|
||||
if (showRestoreCta) {
|
||||
Surface(
|
||||
onClick = {
|
||||
if (canvasRehydratePending) return@Surface
|
||||
viewModel.requestCanvasRehydrate(source = "screen_tab_cta")
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileSurface.copy(alpha = 0.9f),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = restoreCtaText,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Medium),
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopStatusBar(
|
||||
statusText: String,
|
||||
@@ -222,18 +268,21 @@ private fun BottomTabBar(
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(safeInsets),
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().offset(y = (-4).dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -269,22 +318,3 @@ private fun BottomTabBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComingSoonTabScreen(
|
||||
label: String,
|
||||
title: String,
|
||||
description: String,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent)
|
||||
Text(title, style = mobileTitle1, color = mobileText)
|
||||
Text(description, style = mobileBody, color = mobileTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -32,8 +31,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.Button
|
||||
@@ -48,7 +45,7 @@ import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -56,41 +53,33 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
import ai.openclaw.android.WakeWords
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val instanceId by viewModel.instanceId.collectAsState()
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
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 wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||
val deviceModel =
|
||||
remember {
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
@@ -116,14 +105,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
leadingIconColor = mobileTextSecondary,
|
||||
)
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
val commitWakeWords = {
|
||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||
if (parsed != null) {
|
||||
viewModel.setWakeWords(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||
@@ -165,9 +146,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
var micPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// Status text is handled by NodeRuntime.
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
micPermissionGranted = granted
|
||||
}
|
||||
|
||||
val smsPermissionAvailable =
|
||||
@@ -187,6 +175,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
micPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
fun setCameraEnabledChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setCameraEnabled(false)
|
||||
@@ -302,7 +306,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Voice
|
||||
// Voice
|
||||
item {
|
||||
Text(
|
||||
"VOICE",
|
||||
@@ -310,120 +314,48 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val enabled = voiceWakeMode != VoiceWakeMode.Off
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Voice Wake", style = mobileHeadline) },
|
||||
supportingContent = { Text(voiceWakeStatusText, style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { on ->
|
||||
if (on) {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Microphone permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (micPermissionGranted) {
|
||||
"Granted. Use the Voice tab mic button to capture transcript."
|
||||
} else {
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Foreground Only", style = mobileHeadline) },
|
||||
supportingContent = { Text("Listens only while OpenClaw is open.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||
},
|
||||
"Required for Voice tab transcription."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (micPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (micPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Always", style = mobileHeadline) },
|
||||
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = voiceWakeMode == VoiceWakeMode.Always,
|
||||
onClick = {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = wakeWordsText,
|
||||
onValueChange = setWakeWordsText,
|
||||
label = { Text("Wake Words (comma-separated)", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
wakeWordsHadFocus = true
|
||||
} else if (wakeWordsHadFocus) {
|
||||
wakeWordsHadFocus = false
|
||||
commitWakeWords()
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
commitWakeWords()
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = viewModel::resetWakeWordsDefaults,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text("Reset defaults", style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
if (isConnected) {
|
||||
"Any node can edit wake words. Changes sync via the gateway."
|
||||
} else {
|
||||
"Connect to a gateway to sync wake words globally."
|
||||
},
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package ai.openclaw.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import ai.openclaw.android.voice.VoiceConversationRole
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val activity = remember(context) { context.findActivity() }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
val micInputLevel by viewModel.micInputLevel.collectAsState()
|
||||
val micIsSending by viewModel.micIsSending.collectAsState()
|
||||
|
||||
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val showThinkingBubble = micIsSending && !hasStreamingAssistant
|
||||
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
var pendingMicEnable by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
hasMicPermission = context.hasRecordAudioPermission()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val requestMicPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPermission = granted
|
||||
if (granted && pendingMicEnable) {
|
||||
viewModel.setMicEnabled(true)
|
||||
}
|
||||
pendingMicEnable = false
|
||||
}
|
||||
|
||||
LaunchedEffect(micConversation.size, showThinkingBubble) {
|
||||
val total = micConversation.size + if (showThinkingBubble) 1 else 0
|
||||
if (total > 0) {
|
||||
listState.animateScrollToItem(total - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(mobileBackgroundGradient)
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"VOICE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
Text("Voice mode", style = mobileTitle2, color = mobileText)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
|
||||
border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
if (isConnected) "Connected" else "Offline",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = mobileCaption1,
|
||||
color = if (isConnected) mobileAccent else mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Tap the mic and speak. Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(items = micConversation, key = { it.id }) { entry ->
|
||||
VoiceTurnBubble(entry = entry)
|
||||
}
|
||||
|
||||
if (showThinkingBubble) {
|
||||
item {
|
||||
VoiceThinkingBubble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Text(
|
||||
micLiveTranscript!!.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MicWaveform(level = micInputLevel, active = micEnabled)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (micEnabled) {
|
||||
viewModel.setMicEnabled(false)
|
||||
return@Button
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(86.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (micEnabled) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
if (micEnabled) "Tap to stop" else "Tap to speak",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
if (activity == null) {
|
||||
false
|
||||
} else {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
Text(
|
||||
if (showRationale) {
|
||||
"Microphone permission is required for voice mode."
|
||||
} else {
|
||||
"Microphone blocked. Open app settings to enable it."
|
||||
},
|
||||
style = mobileCaption1,
|
||||
color = mobileWarning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = { openAppSettings(context) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
|
||||
) {
|
||||
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
micStatusText,
|
||||
style = mobileCaption1,
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
val isUser = entry.role == VoiceConversationRole.User
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isUser) mobileAccentSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
if (isUser) "You" else "OpenClaw",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceThinkingBubble() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ThinkingDots(color = mobileTextSecondary)
|
||||
Text("OpenClaw is thinking…", style = mobileCallout, color = mobileTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingDots(color: Color) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
ThinkingDot(alpha = 0.38f, color = color)
|
||||
ThinkingDot(alpha = 0.62f, color = color)
|
||||
ThinkingDot(alpha = 0.90f, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingDot(alpha: Float, color: Color) {
|
||||
Surface(
|
||||
modifier = Modifier.size(6.dp).alpha(alpha),
|
||||
shape = CircleShape,
|
||||
color = color,
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MicWaveform(level: Float, active: Boolean) {
|
||||
val transition = rememberInfiniteTransition(label = "voiceWave")
|
||||
val phase by
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
|
||||
label = "voiceWavePhase",
|
||||
)
|
||||
|
||||
val effective = if (active) level.coerceIn(0f, 1f) else 0f
|
||||
val base = max(effective, if (active) 0.05f else 0f)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(16) { index ->
|
||||
val pulse =
|
||||
if (!active) {
|
||||
0f
|
||||
} else {
|
||||
((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
|
||||
}
|
||||
val barHeight = 6.dp + (24.dp * (base * pulse))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(5.dp)
|
||||
.height(barHeight)
|
||||
.background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun Context.findActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -161,6 +162,7 @@ fun ChatComposer(
|
||||
label = "Refresh",
|
||||
icon = Icons.Default.Refresh,
|
||||
enabled = true,
|
||||
compact = true,
|
||||
onClick = onRefresh,
|
||||
)
|
||||
|
||||
@@ -168,6 +170,7 @@ fun ChatComposer(
|
||||
label = "Abort",
|
||||
icon = Icons.Default.Stop,
|
||||
enabled = pendingRunCount > 0,
|
||||
compact = true,
|
||||
onClick = onAbort,
|
||||
)
|
||||
}
|
||||
@@ -196,7 +199,12 @@ fun ChatComposer(
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Send", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
Text(
|
||||
text = "Send",
|
||||
style = mobileHeadline.copy(fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,12 +215,13 @@ private fun SecondaryActionButton(
|
||||
label: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
enabled: Boolean,
|
||||
compact: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.height(44.dp),
|
||||
modifier = if (compact) Modifier.size(44.dp) else Modifier.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
@@ -222,15 +231,17 @@ private fun SecondaryActionButton(
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
contentPadding = if (compact) PaddingValues(0.dp) else ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Icon(icon, contentDescription = label, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = if (enabled) mobileTextSecondary else mobileTextTertiary,
|
||||
)
|
||||
if (!compact) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = if (enabled) mobileTextSecondary else mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,20 @@ package ai.openclaw.android.ui.chat
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -15,18 +25,23 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.android.ui.mobileAccent
|
||||
import ai.openclaw.android.ui.mobileCallout
|
||||
import ai.openclaw.android.ui.mobileCaption1
|
||||
import ai.openclaw.android.ui.mobileCodeBg
|
||||
@@ -34,159 +49,510 @@ import ai.openclaw.android.ui.mobileCodeText
|
||||
import ai.openclaw.android.ui.mobileTextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.commonmark.Extension
|
||||
import org.commonmark.ext.autolink.AutolinkExtension
|
||||
import org.commonmark.ext.gfm.strikethrough.Strikethrough
|
||||
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
|
||||
import org.commonmark.ext.gfm.tables.TableBlock
|
||||
import org.commonmark.ext.gfm.tables.TableBody
|
||||
import org.commonmark.ext.gfm.tables.TableCell
|
||||
import org.commonmark.ext.gfm.tables.TableHead
|
||||
import org.commonmark.ext.gfm.tables.TableRow
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension
|
||||
import org.commonmark.ext.task.list.items.TaskListItemMarker
|
||||
import org.commonmark.ext.task.list.items.TaskListItemsExtension
|
||||
import org.commonmark.node.BlockQuote
|
||||
import org.commonmark.node.BulletList
|
||||
import org.commonmark.node.Code
|
||||
import org.commonmark.node.Document
|
||||
import org.commonmark.node.Emphasis
|
||||
import org.commonmark.node.FencedCodeBlock
|
||||
import org.commonmark.node.Heading
|
||||
import org.commonmark.node.HardLineBreak
|
||||
import org.commonmark.node.HtmlBlock
|
||||
import org.commonmark.node.HtmlInline
|
||||
import org.commonmark.node.Image as MarkdownImage
|
||||
import org.commonmark.node.IndentedCodeBlock
|
||||
import org.commonmark.node.Link
|
||||
import org.commonmark.node.ListItem
|
||||
import org.commonmark.node.Node
|
||||
import org.commonmark.node.OrderedList
|
||||
import org.commonmark.node.Paragraph
|
||||
import org.commonmark.node.SoftLineBreak
|
||||
import org.commonmark.node.StrongEmphasis
|
||||
import org.commonmark.node.Text as MarkdownTextNode
|
||||
import org.commonmark.node.ThematicBreak
|
||||
import org.commonmark.parser.Parser
|
||||
|
||||
private const val LIST_INDENT_DP = 14
|
||||
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
|
||||
|
||||
private val markdownParser: Parser by lazy {
|
||||
val extensions: List<Extension> =
|
||||
listOf(
|
||||
AutolinkExtension.create(),
|
||||
StrikethroughExtension.create(),
|
||||
TablesExtension.create(),
|
||||
TaskListItemsExtension.create(),
|
||||
)
|
||||
Parser.builder()
|
||||
.extensions(extensions)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
val blocks = remember(text) { splitMarkdown(text) }
|
||||
val inlineCodeBg = mobileCodeBg
|
||||
val inlineCodeColor = mobileCodeText
|
||||
val document = remember(text) { markdownParser.parse(text) as Document }
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (b in blocks) {
|
||||
when (b) {
|
||||
is ChatMarkdownBlock.Text -> {
|
||||
val trimmed = b.text.trimEnd()
|
||||
if (trimmed.isEmpty()) continue
|
||||
RenderMarkdownBlocks(
|
||||
start = document.firstChild,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderMarkdownBlocks(
|
||||
start: Node?,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
listDepth: Int,
|
||||
) {
|
||||
var node = start
|
||||
while (node != null) {
|
||||
val current = node
|
||||
when (current) {
|
||||
is Paragraph -> {
|
||||
RenderParagraph(current, textColor = textColor, inlineStyles = inlineStyles)
|
||||
}
|
||||
is Heading -> {
|
||||
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
|
||||
Text(
|
||||
text = headingText,
|
||||
style = headingStyle(current.level),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
is FencedCodeBlock -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = current.literal.orEmpty(), language = current.info?.trim()?.ifEmpty { null })
|
||||
}
|
||||
}
|
||||
is IndentedCodeBlock -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = current.literal.orEmpty(), language = null)
|
||||
}
|
||||
}
|
||||
is BlockQuote -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.background(mobileTextSecondary.copy(alpha = 0.35f)),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RenderMarkdownBlocks(
|
||||
start = current.firstChild,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is BulletList -> {
|
||||
RenderBulletList(
|
||||
list = current,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth,
|
||||
)
|
||||
}
|
||||
is OrderedList -> {
|
||||
RenderOrderedList(
|
||||
list = current,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth,
|
||||
)
|
||||
}
|
||||
is TableBlock -> {
|
||||
RenderTableBlock(
|
||||
table = current,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
)
|
||||
}
|
||||
is ThematicBreak -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(mobileTextSecondary.copy(alpha = 0.25f)),
|
||||
)
|
||||
}
|
||||
is HtmlBlock -> {
|
||||
val literal = current.literal.orEmpty().trim()
|
||||
if (literal.isNotEmpty()) {
|
||||
Text(
|
||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor),
|
||||
style = mobileCallout,
|
||||
text = literal,
|
||||
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
is ChatMarkdownBlock.Code -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = b.code, language = b.language)
|
||||
}
|
||||
}
|
||||
is ChatMarkdownBlock.InlineImage -> {
|
||||
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
|
||||
}
|
||||
}
|
||||
node = current.next
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderParagraph(
|
||||
paragraph: Paragraph,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
) {
|
||||
val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) }
|
||||
if (standaloneImage != null) {
|
||||
InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType)
|
||||
return
|
||||
}
|
||||
|
||||
val annotated = remember(paragraph) { buildInlineMarkdown(paragraph.firstChild, inlineStyles) }
|
||||
if (annotated.text.trimEnd().isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotated,
|
||||
style = mobileCallout,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBulletList(
|
||||
list: BulletList,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
listDepth: Int,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
var item = list.firstChild
|
||||
while (item != null) {
|
||||
if (item is ListItem) {
|
||||
RenderListItem(
|
||||
item = item,
|
||||
markerText = "•",
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth,
|
||||
)
|
||||
}
|
||||
item = item.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderOrderedList(
|
||||
list: OrderedList,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
listDepth: Int,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = (LIST_INDENT_DP * listDepth).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
var index = list.markerStartNumber ?: 1
|
||||
var item = list.firstChild
|
||||
while (item != null) {
|
||||
if (item is ListItem) {
|
||||
RenderListItem(
|
||||
item = item,
|
||||
markerText = "$index.",
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth,
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
item = item.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderListItem(
|
||||
item: ListItem,
|
||||
markerText: String,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
listDepth: Int,
|
||||
) {
|
||||
var contentStart = item.firstChild
|
||||
var marker = markerText
|
||||
val task = contentStart as? TaskListItemMarker
|
||||
if (task != null) {
|
||||
marker = if (task.isChecked) "☑" else "☐"
|
||||
contentStart = task.next
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = marker,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = textColor,
|
||||
modifier = Modifier.width(24.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RenderMarkdownBlocks(
|
||||
start = contentStart,
|
||||
textColor = textColor,
|
||||
inlineStyles = inlineStyles,
|
||||
listDepth = listDepth + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderTableBlock(
|
||||
table: TableBlock,
|
||||
textColor: Color,
|
||||
inlineStyles: InlineStyles,
|
||||
) {
|
||||
val rows = remember(table) { buildTableRows(table, inlineStyles) }
|
||||
if (rows.isEmpty()) return
|
||||
|
||||
val maxCols = rows.maxOf { row -> row.cells.size }.coerceAtLeast(1)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(scrollState)
|
||||
.border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)),
|
||||
) {
|
||||
for (row in rows) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
for (index in 0 until maxCols) {
|
||||
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
|
||||
Text(
|
||||
text = cell,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.width(160.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface ChatMarkdownBlock {
|
||||
data class Text(val text: String) : ChatMarkdownBlock
|
||||
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
|
||||
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
|
||||
}
|
||||
|
||||
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
var idx = 0
|
||||
while (idx < raw.length) {
|
||||
val fenceStart = raw.indexOf("```", startIndex = idx)
|
||||
if (fenceStart < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx)))
|
||||
break
|
||||
private fun buildTableRows(table: TableBlock, inlineStyles: InlineStyles): List<TableRenderRow> {
|
||||
val rows = mutableListOf<TableRenderRow>()
|
||||
var child = table.firstChild
|
||||
while (child != null) {
|
||||
when (child) {
|
||||
is TableHead -> rows.addAll(readTableSection(child, isHeader = true, inlineStyles = inlineStyles))
|
||||
is TableBody -> rows.addAll(readTableSection(child, isHeader = false, inlineStyles = inlineStyles))
|
||||
is TableRow -> rows.add(readTableRow(child, isHeader = false, inlineStyles = inlineStyles))
|
||||
}
|
||||
|
||||
if (fenceStart > idx) {
|
||||
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
|
||||
}
|
||||
|
||||
val langLineStart = fenceStart + 3
|
||||
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
|
||||
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
|
||||
|
||||
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
|
||||
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
|
||||
if (fenceEnd < 0) {
|
||||
out.addAll(splitInlineImages(raw.substring(fenceStart)))
|
||||
break
|
||||
}
|
||||
val code = raw.substring(codeStart, fenceEnd)
|
||||
out.add(ChatMarkdownBlock.Code(code = code, language = language))
|
||||
|
||||
idx = fenceEnd + 3
|
||||
child = child.next
|
||||
}
|
||||
|
||||
return out
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
|
||||
val out = ArrayList<ChatMarkdownBlock>()
|
||||
|
||||
var idx = 0
|
||||
while (idx < text.length) {
|
||||
val m = regex.find(text, startIndex = idx) ?: break
|
||||
val start = m.range.first
|
||||
val end = m.range.last + 1
|
||||
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
|
||||
|
||||
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
|
||||
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
|
||||
if (b64.isNotEmpty()) {
|
||||
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
|
||||
private fun readTableSection(section: Node, isHeader: Boolean, inlineStyles: InlineStyles): List<TableRenderRow> {
|
||||
val rows = mutableListOf<TableRenderRow>()
|
||||
var row = section.firstChild
|
||||
while (row != null) {
|
||||
if (row is TableRow) {
|
||||
rows.add(readTableRow(row, isHeader = isHeader, inlineStyles = inlineStyles))
|
||||
}
|
||||
idx = end
|
||||
row = row.next
|
||||
}
|
||||
|
||||
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
|
||||
return out
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun parseInlineMarkdown(
|
||||
text: String,
|
||||
inlineCodeBg: androidx.compose.ui.graphics.Color,
|
||||
inlineCodeColor: androidx.compose.ui.graphics.Color,
|
||||
): AnnotatedString {
|
||||
if (text.isEmpty()) return AnnotatedString("")
|
||||
private fun readTableRow(row: TableRow, isHeader: Boolean, inlineStyles: InlineStyles): TableRenderRow {
|
||||
val cells = mutableListOf<AnnotatedString>()
|
||||
var cellNode = row.firstChild
|
||||
while (cellNode != null) {
|
||||
if (cellNode is TableCell) {
|
||||
cells.add(buildInlineMarkdown(cellNode.firstChild, inlineStyles))
|
||||
}
|
||||
cellNode = cellNode.next
|
||||
}
|
||||
return TableRenderRow(isHeader = isHeader, cells = cells)
|
||||
}
|
||||
|
||||
val out = buildAnnotatedString {
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
if (text.startsWith("**", startIndex = i)) {
|
||||
val end = text.indexOf("**", startIndex = i + 2)
|
||||
if (end > i + 2) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append(text.substring(i + 2, end))
|
||||
}
|
||||
i = end + 2
|
||||
continue
|
||||
private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
appendInlineNode(
|
||||
node = start,
|
||||
inlineCodeBg = inlineStyles.inlineCodeBg,
|
||||
inlineCodeColor = inlineStyles.inlineCodeColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.appendInlineNode(
|
||||
node: Node?,
|
||||
inlineCodeBg: Color,
|
||||
inlineCodeColor: Color,
|
||||
) {
|
||||
var current = node
|
||||
while (current != null) {
|
||||
when (current) {
|
||||
is MarkdownTextNode -> append(current.literal)
|
||||
is SoftLineBreak -> append('\n')
|
||||
is HardLineBreak -> append('\n')
|
||||
is Code -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = inlineCodeBg,
|
||||
color = inlineCodeColor,
|
||||
),
|
||||
) {
|
||||
append(current.literal)
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '`') {
|
||||
val end = text.indexOf('`', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
background = inlineCodeBg,
|
||||
color = inlineCodeColor,
|
||||
),
|
||||
) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
is Emphasis -> {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
|
||||
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
|
||||
val end = text.indexOf('*', startIndex = i + 1)
|
||||
if (end > i + 1) {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(text.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
is StrongEmphasis -> {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
|
||||
append(text[i])
|
||||
i += 1
|
||||
is Strikethrough -> {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is Link -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = mobileAccent,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
is MarkdownImage -> {
|
||||
val alt = buildPlainText(current.firstChild)
|
||||
if (alt.isNotBlank()) {
|
||||
append(alt)
|
||||
} else {
|
||||
append("image")
|
||||
}
|
||||
}
|
||||
is HtmlInline -> {
|
||||
if (!current.literal.isNullOrBlank()) {
|
||||
append(current.literal)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
}
|
||||
}
|
||||
current = current.next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildPlainText(start: Node?): String {
|
||||
val sb = StringBuilder()
|
||||
var node = start
|
||||
while (node != null) {
|
||||
when (node) {
|
||||
is MarkdownTextNode -> sb.append(node.literal)
|
||||
is SoftLineBreak, is HardLineBreak -> sb.append('\n')
|
||||
else -> sb.append(buildPlainText(node.firstChild))
|
||||
}
|
||||
node = node.next
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
|
||||
val only = paragraph.firstChild as? MarkdownImage ?: return null
|
||||
if (only.next != null) return null
|
||||
return parseDataImageDestination(only.destination)
|
||||
}
|
||||
|
||||
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
val raw = destination?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
val match = dataImageRegex.matchEntire(raw) ?: return null
|
||||
val subtype = match.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png"
|
||||
val base64 = match.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
|
||||
if (base64.isEmpty()) return null
|
||||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
private fun headingStyle(level: Int): TextStyle {
|
||||
return when (level.coerceIn(1, 6)) {
|
||||
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private data class InlineStyles(
|
||||
val inlineCodeBg: Color,
|
||||
val inlineCodeColor: Color,
|
||||
)
|
||||
|
||||
private data class TableRenderRow(
|
||||
val isHeader: Boolean,
|
||||
val cells: List<AnnotatedString>,
|
||||
)
|
||||
|
||||
private data class ParsedDataImage(
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -122,33 +123,35 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
|
||||
ChatComposer(
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
attachments = attachments,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
onRefresh = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
},
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
attachments.map { att ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
)
|
||||
}
|
||||
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||
attachments.clear()
|
||||
},
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth().imePadding()) {
|
||||
ChatComposer(
|
||||
healthOk = healthOk,
|
||||
thinkingLevel = thinkingLevel,
|
||||
pendingRunCount = pendingRunCount,
|
||||
attachments = attachments,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||
onRefresh = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
},
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
attachments.map { att ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
)
|
||||
}
|
||||
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||
attachments.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
package ai.openclaw.android.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
enum class VoiceConversationRole {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
data class VoiceConversationEntry(
|
||||
val id: String,
|
||||
val role: VoiceConversationRole,
|
||||
val text: String,
|
||||
val isStreaming: Boolean = false,
|
||||
)
|
||||
|
||||
class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val sendToGateway: suspend (String) -> String?,
|
||||
) {
|
||||
companion object {
|
||||
private const val speechMinSessionMs = 30_000L
|
||||
private const val speechCompleteSilenceMs = 1_500L
|
||||
private const val speechPossibleSilenceMs = 900L
|
||||
private const val maxConversationEntries = 40
|
||||
private const val pendingRunTimeoutMs = 45_000L
|
||||
}
|
||||
|
||||
private data class QueuedUtterance(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _micEnabled = MutableStateFlow(false)
|
||||
val micEnabled: StateFlow<Boolean> = _micEnabled
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening: StateFlow<Boolean> = _isListening
|
||||
|
||||
private val _statusText = MutableStateFlow("Mic off")
|
||||
val statusText: StateFlow<String> = _statusText
|
||||
|
||||
private val _liveTranscript = MutableStateFlow<String?>(null)
|
||||
val liveTranscript: StateFlow<String?> = _liveTranscript
|
||||
|
||||
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
|
||||
val queuedMessages: StateFlow<List<String>> = _queuedMessages
|
||||
|
||||
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
|
||||
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
|
||||
|
||||
private val _inputLevel = MutableStateFlow(0f)
|
||||
val inputLevel: StateFlow<Float> = _inputLevel
|
||||
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
val isSending: StateFlow<Boolean> = _isSending
|
||||
|
||||
private val messageQueue = ArrayDeque<QueuedUtterance>()
|
||||
private val sessionSegments = mutableListOf<String>()
|
||||
private var lastFinalSegment: String? = null
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingAssistantEntryId: String? = null
|
||||
private var gatewayConnected = false
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var pendingRunTimeoutJob: Job? = null
|
||||
private var stopRequested = false
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
if (_micEnabled.value == enabled) return
|
||||
_micEnabled.value = enabled
|
||||
if (enabled) {
|
||||
start()
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
stop()
|
||||
flushSessionToQueue()
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
fun onGatewayConnectionChanged(connected: Boolean) {
|
||||
gatewayConnected = connected
|
||||
if (connected) {
|
||||
sendQueuedIfIdle()
|
||||
return
|
||||
}
|
||||
if (messageQueue.isNotEmpty()) {
|
||||
_statusText.value = queuedWaitingStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
if (event != "chat") return
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
val payload =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
|
||||
val runId = pendingRunId ?: return
|
||||
val eventRunId = payload["runId"].asStringOrNull() ?: return
|
||||
if (eventRunId != runId) return
|
||||
|
||||
when (payload["state"].asStringOrNull()) {
|
||||
"delta" -> {
|
||||
val deltaText = parseAssistantText(payload)
|
||||
if (!deltaText.isNullOrBlank()) {
|
||||
upsertPendingAssistant(text = deltaText.trim(), isStreaming = true)
|
||||
}
|
||||
}
|
||||
"final" -> {
|
||||
val finalText = parseAssistantText(payload)?.trim().orEmpty()
|
||||
if (finalText.isNotEmpty()) {
|
||||
upsertPendingAssistant(text = finalText, isStreaming = false)
|
||||
} else if (pendingAssistantEntryId != null) {
|
||||
updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
|
||||
}
|
||||
completePendingTurn()
|
||||
}
|
||||
"error" -> {
|
||||
val errorMessage = payload["errorMessage"].asStringOrNull()?.trim().orEmpty().ifEmpty { "Voice request failed" }
|
||||
upsertPendingAssistant(text = errorMessage, isStreaming = false)
|
||||
completePendingTurn()
|
||||
}
|
||||
"aborted" -> {
|
||||
upsertPendingAssistant(text = "Response aborted", isStreaming = false)
|
||||
completePendingTurn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
stopRequested = false
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
_statusText.value = "Speech recognizer unavailable"
|
||||
_micEnabled.value = false
|
||||
return
|
||||
}
|
||||
if (!hasMicPermission()) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
_micEnabled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
try {
|
||||
if (recognizer == null) {
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
}
|
||||
startListeningSession()
|
||||
} catch (err: Throwable) {
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
_micEnabled.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
_isListening.value = false
|
||||
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
|
||||
_inputLevel.value = 0f
|
||||
mainHandler.post {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListeningSession() {
|
||||
val recognizerInstance = recognizer ?: return
|
||||
val intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
|
||||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
|
||||
speechPossibleSilenceMs,
|
||||
)
|
||||
}
|
||||
_statusText.value =
|
||||
when {
|
||||
_isSending.value -> "Listening · sending queued voice"
|
||||
messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued"
|
||||
else -> "Listening"
|
||||
}
|
||||
_isListening.value = true
|
||||
recognizerInstance.startListening(intent)
|
||||
}
|
||||
|
||||
private fun scheduleRestart(delayMs: Long = 300L) {
|
||||
if (stopRequested) return
|
||||
if (!_micEnabled.value) return
|
||||
restartJob?.cancel()
|
||||
restartJob =
|
||||
scope.launch {
|
||||
delay(delayMs)
|
||||
mainHandler.post {
|
||||
if (stopRequested || !_micEnabled.value) return@post
|
||||
try {
|
||||
startListeningSession()
|
||||
} catch (_: Throwable) {
|
||||
// retry through onError
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushSessionToQueue() {
|
||||
val message = sessionSegments.joinToString(" ").trim()
|
||||
sessionSegments.clear()
|
||||
_liveTranscript.value = null
|
||||
lastFinalSegment = null
|
||||
if (message.isEmpty()) return
|
||||
|
||||
appendConversation(
|
||||
role = VoiceConversationRole.User,
|
||||
text = message,
|
||||
)
|
||||
messageQueue.addLast(QueuedUtterance(text = message))
|
||||
publishQueue()
|
||||
}
|
||||
|
||||
private fun publishQueue() {
|
||||
_queuedMessages.value = messageQueue.map { it.text }
|
||||
}
|
||||
|
||||
private fun sendQueuedIfIdle() {
|
||||
if (_isSending.value) return
|
||||
if (messageQueue.isEmpty()) {
|
||||
if (_micEnabled.value) {
|
||||
_statusText.value = "Listening"
|
||||
} else {
|
||||
_statusText.value = "Mic off"
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!gatewayConnected) {
|
||||
_statusText.value = queuedWaitingStatus()
|
||||
return
|
||||
}
|
||||
|
||||
val next = messageQueue.first()
|
||||
_isSending.value = true
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
_statusText.value = if (_micEnabled.value) "Listening · sending queued voice" else "Sending queued voice"
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val runId = sendToGateway(next.text)
|
||||
pendingRunId = runId
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
messageQueue.removeFirst()
|
||||
publishQueue()
|
||||
_isSending.value = false
|
||||
pendingAssistantEntryId = null
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
armPendingRunTimeout(runId)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
_isSending.value = false
|
||||
pendingRunId = null
|
||||
pendingAssistantEntryId = null
|
||||
_statusText.value =
|
||||
if (!gatewayConnected) {
|
||||
queuedWaitingStatus()
|
||||
} else {
|
||||
"Send failed: ${err.message ?: err::class.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun armPendingRunTimeout(runId: String) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob =
|
||||
scope.launch {
|
||||
delay(pendingRunTimeoutMs)
|
||||
if (pendingRunId != runId) return@launch
|
||||
pendingRunId = null
|
||||
pendingAssistantEntryId = null
|
||||
_isSending.value = false
|
||||
_statusText.value =
|
||||
if (gatewayConnected) {
|
||||
"Voice reply timed out; retrying queued turn"
|
||||
} else {
|
||||
queuedWaitingStatus()
|
||||
}
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePendingTurn() {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
if (messageQueue.isNotEmpty()) {
|
||||
messageQueue.removeFirst()
|
||||
publishQueue()
|
||||
}
|
||||
pendingRunId = null
|
||||
pendingAssistantEntryId = null
|
||||
_isSending.value = false
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
|
||||
private fun queuedWaitingStatus(): String {
|
||||
return "${messageQueue.size} queued · waiting for gateway"
|
||||
}
|
||||
|
||||
private fun appendConversation(
|
||||
role: VoiceConversationRole,
|
||||
text: String,
|
||||
isStreaming: Boolean = false,
|
||||
): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
_conversation.value =
|
||||
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
|
||||
.takeLast(maxConversationEntries)
|
||||
return id
|
||||
}
|
||||
|
||||
private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
|
||||
val current = _conversation.value
|
||||
_conversation.value =
|
||||
current.map { entry ->
|
||||
if (entry.id == id) {
|
||||
val updatedText = text ?: entry.text
|
||||
entry.copy(text = updatedText, isStreaming = isStreaming)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
|
||||
val currentId = pendingAssistantEntryId
|
||||
if (currentId == null) {
|
||||
pendingAssistantEntryId =
|
||||
appendConversation(
|
||||
role = VoiceConversationRole.Assistant,
|
||||
text = text,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
return
|
||||
}
|
||||
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
|
||||
}
|
||||
|
||||
private fun onFinalTranscript(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
_liveTranscript.value = trimmed
|
||||
if (lastFinalSegment == trimmed) return
|
||||
lastFinalSegment = trimmed
|
||||
sessionSegments.add(trimmed)
|
||||
}
|
||||
|
||||
private fun disableMic(status: String) {
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
_micEnabled.value = false
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
_statusText.value = status
|
||||
mainHandler.post {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasMicPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAssistantText(payload: JsonObject): String? {
|
||||
val message = payload["message"].asObjectOrNull() ?: return null
|
||||
if (message["role"].asStringOrNull() != "assistant") return null
|
||||
val content = message["content"] as? JsonArray ?: return null
|
||||
|
||||
val parts =
|
||||
content.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
if (obj["type"].asStringOrNull() != "text") return@mapNotNull null
|
||||
obj["text"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
if (parts.isEmpty()) return null
|
||||
return parts.joinToString("\n")
|
||||
}
|
||||
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
_isListening.value = true
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
|
||||
_inputLevel.value = level
|
||||
}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
_inputLevel.value = 0f
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
if (stopRequested) return
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
val status =
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
|
||||
SpeechRecognizer.ERROR_CLIENT -> "Client error"
|
||||
SpeechRecognizer.ERROR_NETWORK -> "Network error"
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
|
||||
SpeechRecognizer.ERROR_SERVER -> "Server error"
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
|
||||
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
|
||||
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
|
||||
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
|
||||
else -> "Speech error ($error)"
|
||||
}
|
||||
_statusText.value = status
|
||||
|
||||
if (
|
||||
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
|
||||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
|
||||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
|
||||
) {
|
||||
disableMic(status)
|
||||
return
|
||||
}
|
||||
|
||||
val restartDelayMs =
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_NO_MATCH,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
|
||||
-> 1_200L
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
|
||||
else -> 600L
|
||||
}
|
||||
scheduleRestart(delayMs = restartDelayMs)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
onFinalTranscript(text)
|
||||
flushSessionToQueue()
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
_liveTranscript.value = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? =
|
||||
this as? JsonObject
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -385,12 +385,12 @@ class TalkModeManager(
|
||||
val key = sessionKey.trim()
|
||||
if (key.isEmpty()) return
|
||||
if (chatSubscribedSessionKey == key) return
|
||||
try {
|
||||
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
val sent = session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||
if (sent) {
|
||||
chatSubscribedSessionKey = key
|
||||
Log.d(tag, "chat.subscribe ok sessionKey=$key")
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "chat.subscribe failed sessionKey=$key err=${err.message ?: err::class.java.simpleName}")
|
||||
} else {
|
||||
Log.w(tag, "chat.subscribe failed sessionKey=$key")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceAuthPayloadTest {
|
||||
@Test
|
||||
fun buildV3_matchesCanonicalVector() {
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = "dev-1",
|
||||
clientId = "openclaw-macos",
|
||||
clientMode = "ui",
|
||||
role = "operator",
|
||||
scopes = listOf("operator.admin", "operator.read"),
|
||||
signedAtMs = 1_700_000_000_000,
|
||||
token = "tok-123",
|
||||
nonce = "nonce-abc",
|
||||
platform = " IOS ",
|
||||
deviceFamily = " iPhone ",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeMetadataField_asciiOnlyLowercase() {
|
||||
assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS "))
|
||||
assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC "))
|
||||
assertEquals("", DeviceAuthPayload.normalizeMetadataField(null))
|
||||
}
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeErrorParserTest {
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code)
|
||||
assertEquals("grant Camera permission", parsed.message)
|
||||
assertTrue(parsed.hadExplicitCode)
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("IllegalStateException: boom")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("IllegalStateException: boom", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() {
|
||||
val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("fallback", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DeviceHandlerTest {
|
||||
@Test
|
||||
fun handleDeviceInfo_returnsStablePayload() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceInfo(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("deviceName").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("modelIdentifier").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("systemVersion").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("appVersion").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("appBuild").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("locale").jsonPrimitive.content.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceStatus_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceStatus(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val battery = payload.getValue("battery").jsonObject
|
||||
val storage = payload.getValue("storage").jsonObject
|
||||
val thermal = payload.getValue("thermal").jsonObject
|
||||
val network = payload.getValue("network").jsonObject
|
||||
|
||||
val state = battery.getValue("state").jsonPrimitive.content
|
||||
assertTrue(state in setOf("unknown", "unplugged", "charging", "full"))
|
||||
battery["level"]?.jsonPrimitive?.double?.let { level ->
|
||||
assertTrue(level in 0.0..1.0)
|
||||
}
|
||||
battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
|
||||
|
||||
val totalBytes = storage.getValue("totalBytes").jsonPrimitive.content.toLong()
|
||||
val freeBytes = storage.getValue("freeBytes").jsonPrimitive.content.toLong()
|
||||
val usedBytes = storage.getValue("usedBytes").jsonPrimitive.content.toLong()
|
||||
assertTrue(totalBytes >= 0L)
|
||||
assertTrue(freeBytes >= 0L)
|
||||
assertTrue(usedBytes >= 0L)
|
||||
assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes)
|
||||
|
||||
val thermalState = thermal.getValue("state").jsonPrimitive.content
|
||||
assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical"))
|
||||
|
||||
val networkStatus = network.getValue("status").jsonPrimitive.content
|
||||
assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection"))
|
||||
val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") })
|
||||
|
||||
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
val jsonString = payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(jsonString).jsonObject
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationsHandlerTest {
|
||||
@Test
|
||||
fun notificationsListReturnsStatusPayloadWhenDisabled() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertFalse(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(0, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListRequestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n1")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(1, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListDoesNotRequestRebindWhenConnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
assertNull(sanitizeNotificationText(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextTrimsAndTruncates() {
|
||||
val value = " ${"x".repeat(600)} "
|
||||
val sanitized = sanitizeNotificationText(value)
|
||||
|
||||
assertEquals(512, sanitized?.length)
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun sampleEntry(key: String): DeviceNotificationEntry =
|
||||
DeviceNotificationEntry(
|
||||
key = key,
|
||||
packageName = "com.example.app",
|
||||
title = "Title",
|
||||
text = "Text",
|
||||
subText = null,
|
||||
category = null,
|
||||
channelId = null,
|
||||
postTimeMs = 123L,
|
||||
isOngoing = false,
|
||||
isClearable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeNotificationsStateProvider(
|
||||
private val snapshot: DeviceNotificationSnapshot,
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
}
|
||||
+14
@@ -26,10 +26,24 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("camera", OpenClawCapability.Camera.rawValue)
|
||||
assertEquals("screen", OpenClawCapability.Screen.rawValue)
|
||||
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
|
||||
assertEquals("location", OpenClawCapability.Location.rawValue)
|
||||
assertEquals("sms", OpenClawCapability.Sms.rawValue)
|
||||
assertEquals("device", OpenClawCapability.Device.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun screenCommandsUseStableStrings() {
|
||||
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceCommandsUseStableStrings() {
|
||||
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package ai.openclaw.android.ui
|
||||
|
||||
import java.util.Base64
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayConfigResolverTest {
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
|
||||
val qrJson =
|
||||
"""
|
||||
{
|
||||
"setupCode": "$setupCode",
|
||||
"gatewayUrl": "wss://gateway.example:18789",
|
||||
"auth": "password",
|
||||
"urlSource": "gateway.remote.url"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsInvalidInput() {
|
||||
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
|
||||
val qrJson = """{"setupCode":"invalid"}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
|
||||
val qrJson = """{"setupCode":{"nested":"value"}}"""
|
||||
val resolved = resolveScannedSetupCode(qrJson)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
private fun encodeSetupCode(payloadJson: String): String {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.android.benchmark"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
|
||||
}
|
||||
|
||||
targetProjectPath = ":app"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
|
||||
implementation("androidx.test.ext:junit:1.2.1")
|
||||
implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06")
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package ai.openclaw.android.benchmark
|
||||
|
||||
import androidx.benchmark.macro.CompilationMode
|
||||
import androidx.benchmark.macro.FrameTimingMetric
|
||||
import androidx.benchmark.macro.StartupMode
|
||||
import androidx.benchmark.macro.StartupTimingMetric
|
||||
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StartupMacrobenchmark {
|
||||
@get:Rule
|
||||
val benchmarkRule = MacrobenchmarkRule()
|
||||
|
||||
private val packageName = "ai.openclaw.android"
|
||||
|
||||
@Test
|
||||
fun coldStartup() {
|
||||
runBenchmarkOrSkip {
|
||||
benchmarkRule.measureRepeated(
|
||||
packageName = packageName,
|
||||
metrics = listOf(StartupTimingMetric()),
|
||||
startupMode = StartupMode.COLD,
|
||||
compilationMode = CompilationMode.None(),
|
||||
iterations = 10,
|
||||
) {
|
||||
pressHome()
|
||||
startActivityAndWait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupAndScrollFrameTiming() {
|
||||
runBenchmarkOrSkip {
|
||||
benchmarkRule.measureRepeated(
|
||||
packageName = packageName,
|
||||
metrics = listOf(FrameTimingMetric()),
|
||||
startupMode = StartupMode.WARM,
|
||||
compilationMode = CompilationMode.None(),
|
||||
iterations = 10,
|
||||
) {
|
||||
startActivityAndWait()
|
||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
val x = device.displayWidth / 2
|
||||
val yStart = (device.displayHeight * 0.8f).toInt()
|
||||
val yEnd = (device.displayHeight * 0.25f).toInt()
|
||||
repeat(4) {
|
||||
device.swipe(x, yStart, x, yEnd, 24)
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runBenchmarkOrSkip(run: () -> Unit) {
|
||||
try {
|
||||
run()
|
||||
} catch (err: IllegalStateException) {
|
||||
val message = err.message.orEmpty()
|
||||
val knownDeviceIssue =
|
||||
message.contains("Unable to confirm activity launch completion") ||
|
||||
message.contains("no renderthread slices", ignoreCase = true)
|
||||
if (knownDeviceIssue) {
|
||||
assumeTrue("Skipping benchmark on this device: $message", false)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||
id("com.android.test") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
||||
}
|
||||
|
||||
@@ -11,5 +11,4 @@ android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.r8.optimizedResourceShrinking=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
android.newDsl=true
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||
CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup"
|
||||
BASELINE_JSON=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-startup-benchmark.sh [--baseline <benchmarkData.json>]
|
||||
|
||||
Runs cold-start macrobenchmark only, then prints a compact summary.
|
||||
Also saves a timestamped snapshot JSON under benchmark/results/.
|
||||
If --baseline is omitted, compares against latest previous snapshot when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--baseline)
|
||||
BASELINE_JSON="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v adb >/dev/null 2>&1; then
|
||||
echo "adb required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)"
|
||||
trap 'rm -f "$run_log"' EXIT
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
|
||||
./gradlew :benchmark:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \
|
||||
--console=plain \
|
||||
>"$run_log" 2>&1
|
||||
|
||||
latest_json="$(
|
||||
find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \
|
||||
-name '*benchmarkData.json' -type f \
|
||||
| while IFS= read -r file; do
|
||||
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
|
||||
done \
|
||||
| sort -nr \
|
||||
| head -n1 \
|
||||
| cut -f2-
|
||||
)"
|
||||
|
||||
if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then
|
||||
echo "benchmarkData.json not found after run." >&2
|
||||
tail -n 120 "$run_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
snapshot_json="$RESULTS_DIR/startup-$timestamp.json"
|
||||
cp "$latest_json" "$snapshot_json"
|
||||
|
||||
median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")"
|
||||
min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")"
|
||||
max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")"
|
||||
cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")"
|
||||
device="$(jq -r '.context.build.model' "$snapshot_json")"
|
||||
sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")"
|
||||
runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")"
|
||||
|
||||
printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \
|
||||
"$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk"
|
||||
echo "snapshot_json=$snapshot_json"
|
||||
|
||||
if [[ -z "$BASELINE_JSON" ]]; then
|
||||
BASELINE_JSON="$(
|
||||
find "$RESULTS_DIR" -name 'startup-*.json' -type f \
|
||||
| while IFS= read -r file; do
|
||||
if [[ "$file" == "$snapshot_json" ]]; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
|
||||
done \
|
||||
| sort -nr \
|
||||
| head -n1 \
|
||||
| cut -f2-
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE_JSON" ]]; then
|
||||
if [[ ! -f "$BASELINE_JSON" ]]; then
|
||||
echo "Baseline file missing: $BASELINE_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")"
|
||||
delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')"
|
||||
delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')"
|
||||
echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%"
|
||||
fi
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
PACKAGE="ai.openclaw.android"
|
||||
ACTIVITY=".MainActivity"
|
||||
DURATION_SECONDS="10"
|
||||
OUTPUT_PERF_DATA=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-startup-hotspots.sh [--package <pkg>] [--activity <activity>] [--duration <sec>] [--out <perf.data>]
|
||||
|
||||
Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries.
|
||||
Default package/activity target OpenClaw Android startup.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--activity)
|
||||
ACTIVITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--duration)
|
||||
DURATION_SECONDS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
OUTPUT_PERF_DATA="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "uv required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v adb >/dev/null 2>&1; then
|
||||
echo "adb required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUTPUT_PERF_DATA" ]]; then
|
||||
OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data"
|
||||
fi
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
simpleperf_dir=""
|
||||
if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf"
|
||||
elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf"
|
||||
else
|
||||
latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)"
|
||||
if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="$latest_simpleperf"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$simpleperf_dir" ]]; then
|
||||
echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_profiler="$simpleperf_dir/app_profiler.py"
|
||||
report_py="$simpleperf_dir/report.py"
|
||||
ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)"
|
||||
|
||||
tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
capture_log="$tmp_dir/capture.log"
|
||||
dso_csv="$tmp_dir/dso.csv"
|
||||
symbols_csv="$tmp_dir/symbols.csv"
|
||||
children_txt="$tmp_dir/children.txt"
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1
|
||||
|
||||
if ! uv run --no-project python3 "$app_profiler" \
|
||||
-p "$PACKAGE" \
|
||||
-a "$ACTIVITY" \
|
||||
-o "$OUTPUT_PERF_DATA" \
|
||||
--ndk_path "$ndk_path" \
|
||||
-r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \
|
||||
>"$capture_log" 2>&1; then
|
||||
echo "simpleperf capture failed. tail(capture_log):" >&2
|
||||
tail -n 120 "$capture_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--sort dso \
|
||||
--csv \
|
||||
--csv-separator "|" \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$dso_csv" 2>"$tmp_dir/report-dso.err"
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--sort dso,symbol \
|
||||
--csv \
|
||||
--csv-separator "|" \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$symbols_csv" 2>"$tmp_dir/report-symbols.err"
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--children \
|
||||
--sort dso,symbol \
|
||||
-n \
|
||||
--percent-limit 0.2 \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$children_txt" 2>"$tmp_dir/report-children.err"
|
||||
|
||||
clean_csv() {
|
||||
awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1"
|
||||
}
|
||||
|
||||
echo "perf_data=$OUTPUT_PERF_DATA"
|
||||
echo
|
||||
echo "top_dso_self:"
|
||||
clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}'
|
||||
echo
|
||||
echo "top_symbols_self:"
|
||||
clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}'
|
||||
echo
|
||||
echo "app_path_clues_children:"
|
||||
rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true
|
||||
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "OpenClawNodeAndroid"
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -6,7 +6,7 @@ final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "bot.molt.ios.network-status")
|
||||
let queue = DispatchQueue(label: "ai.openclaw.ios.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
|
||||
@@ -104,7 +104,7 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "bot.molt.ios.gateway-discovery.\(domain)"))
|
||||
browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.24</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "bot.molt.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "ai.openclaw.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -22,7 +22,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@@ -326,10 +325,6 @@ struct SettingsTab: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.featureToggle(
|
||||
"Voice Directive Hint",
|
||||
isOn: self.$talkVoiceDirectiveHintEnabled,
|
||||
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
|
||||
self.featureToggle(
|
||||
"Show Talk Button",
|
||||
isOn: self.$talkButtonEnabled,
|
||||
|
||||
@@ -95,7 +95,7 @@ final class TalkModeManager: NSObject {
|
||||
private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState?
|
||||
private var incrementalSpeechPrefetchMonitorTask: Task<Void, Never>?
|
||||
|
||||
private let logger = Logger(subsystem: "bot.molt", category: "TalkMode")
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "TalkMode")
|
||||
|
||||
init(allowSimulatorCapture: Bool = false) {
|
||||
self.allowSimulatorCapture = allowSimulatorCapture
|
||||
@@ -850,11 +850,10 @@ final class TalkModeManager: NSObject {
|
||||
private func buildPrompt(transcript: String) -> String {
|
||||
let interrupted = self.lastInterruptedAtSeconds
|
||||
self.lastInterruptedAtSeconds = nil
|
||||
let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
|
||||
return TalkPromptBuilder.build(
|
||||
transcript: transcript,
|
||||
interruptedAtSeconds: interrupted,
|
||||
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
|
||||
includeVoiceDirectiveHint: false)
|
||||
}
|
||||
|
||||
private enum ChatCompletionState: CustomStringConvertible {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.24</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Testing
|
||||
|
||||
@Suite struct KeychainStoreTests {
|
||||
@Test func saveLoadUpdateDeleteRoundTrip() {
|
||||
let service = "bot.molt.tests.\(UUID().uuidString)"
|
||||
let service = "ai.openclaw.tests.\(UUID().uuidString)"
|
||||
let account = "value"
|
||||
|
||||
#expect(KeychainStore.delete(service: service, account: account))
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
app_identifier("bot.molt.ios")
|
||||
app_identifier("ai.openclaw.ios")
|
||||
|
||||
# Auth is expected via App Store Connect API key.
|
||||
# Provide either:
|
||||
|
||||
+10
-10
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -146,8 +146,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -176,8 +176,8 @@ targets:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -200,8 +200,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -234,5 +234,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@State private var statusText: String?
|
||||
@State private var autoDetectClipboard = true
|
||||
@State private var autoConnectClipboard = true
|
||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
|
||||
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode != .local {
|
||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(self.oauthStatus.shortDescription)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(OpenClawOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startOAuth()
|
||||
} label: {
|
||||
if self.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connectionMode != .local || self.busy)
|
||||
|
||||
if self.pkce != nil {
|
||||
Button("Cancel") {
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if self.pkce != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste `code#state`")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("code#state", text: self.$code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.refresh()
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
if imported != nil {
|
||||
self.statusText = "Imported existing OAuth credentials."
|
||||
}
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.pkce = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishOAuth() async {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
guard let pkce = self.pkce else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
||||
self.statusText = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func pollClipboardIfNeeded() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard self.pkce != nil else { return }
|
||||
guard !self.busy else { return }
|
||||
guard self.autoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.lastPasteboardChangeCount else { return }
|
||||
self.lastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.code != next {
|
||||
self.code = next
|
||||
self.statusText = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.autoConnectClipboard else { return }
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension AnthropicAuthControls {
|
||||
init(
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
|
||||
pkce: AnthropicOAuth.PKCE? = nil,
|
||||
code: String = "",
|
||||
busy: Bool = false,
|
||||
statusText: String? = nil,
|
||||
autoDetectClipboard: Bool = true,
|
||||
autoConnectClipboard: Bool = true)
|
||||
{
|
||||
self.connectionMode = connectionMode
|
||||
self._oauthStatus = State(initialValue: oauthStatus)
|
||||
self._pkce = State(initialValue: pkce)
|
||||
self._code = State(initialValue: code)
|
||||
self._busy = State(initialValue: busy)
|
||||
self._statusText = State(initialValue: statusText)
|
||||
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
||||
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
||||
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,383 +0,0 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
struct AnthropicOAuthCredentials: Codable {
|
||||
let type: String
|
||||
let refresh: String
|
||||
let access: String
|
||||
let expires: Int64
|
||||
}
|
||||
|
||||
enum AnthropicAuthMode: Equatable {
|
||||
case oauthFile
|
||||
case oauthEnv
|
||||
case apiKeyEnv
|
||||
case missing
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (OpenClaw token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
switch self {
|
||||
case .missing: false
|
||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore
|
||||
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return .oauthEnv
|
||||
}
|
||||
|
||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty
|
||||
{
|
||||
return .apiKeyEnv
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicOAuth {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth")
|
||||
|
||||
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
||||
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
||||
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||
private static let scopes = "org:create_api_key user:profile user:inference"
|
||||
|
||||
struct PKCE {
|
||||
let verifier: String
|
||||
let challenge: String
|
||||
}
|
||||
|
||||
static func generatePKCE() throws -> PKCE {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
let verifier = Data(bytes).base64URLEncodedString()
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
let challenge = Data(hash).base64URLEncodedString()
|
||||
return PKCE(verifier: verifier, challenge: challenge)
|
||||
}
|
||||
|
||||
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
||||
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "code", value: "true"),
|
||||
URLQueryItem(name: "client_id", value: self.clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
||||
URLQueryItem(name: "scope", value: self.scopes),
|
||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
// Match legacy flow: state is the verifier.
|
||||
URLQueryItem(name: "state", value: pkce.verifier),
|
||||
]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func exchangeCode(
|
||||
code: String,
|
||||
state: String,
|
||||
verifier: String) async throws -> AnthropicOAuthCredentials
|
||||
{
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.clientId,
|
||||
"code": code,
|
||||
"state": state,
|
||||
"redirect_uri": self.redirectURI,
|
||||
"code_verifier": verifier,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = decoded?["refresh_token"] as? String
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let refresh, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
|
||||
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.clientId,
|
||||
"refresh_token": refreshToken,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR"
|
||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
case unreadableFile
|
||||
case invalidJSON
|
||||
case missingProviderEntry
|
||||
case missingTokens
|
||||
case connected(expiresAtMs: Int64?)
|
||||
|
||||
var isConnected: Bool {
|
||||
if case .connected = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "OpenClaw OAuth token file not found"
|
||||
case .unreadableFile: "OpenClaw OAuth token file not readable"
|
||||
case .invalidJSON: "OpenClaw OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "OpenClaw OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||
}
|
||||
|
||||
static func legacyOAuthURLs() -> [URL] {
|
||||
var urls: [URL] = []
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||
}
|
||||
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
||||
|
||||
var seen = Set<String>()
|
||||
return urls.filter { url in
|
||||
let path = url.standardizedFileURL.path
|
||||
if seen.contains(path) { return false }
|
||||
seen.insert(path)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||
let dest = self.oauthURL()
|
||||
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
|
||||
|
||||
for url in self.legacyOAuthURLs() {
|
||||
guard FileManager().fileExists(atPath: url.path) else { continue }
|
||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||
guard let storage = self.loadStorage(at: url) else { continue }
|
||||
do {
|
||||
try self.saveStorage(storage)
|
||||
return url
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||
}
|
||||
|
||||
static func hasAnthropicOAuth() -> Bool {
|
||||
self.anthropicOAuthStatus().isConnected
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||
guard let storage = json as? [String: Any] else { return .invalidJSON }
|
||||
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
|
||||
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
|
||||
|
||||
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
|
||||
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
|
||||
|
||||
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
|
||||
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
|
||||
ms
|
||||
} else if let number = expiresAny as? NSNumber {
|
||||
number.int64Value
|
||||
} else if let ms = expiresAny as? Double {
|
||||
Int64(ms)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return .connected(expiresAtMs: expiresAtMs)
|
||||
}
|
||||
|
||||
static func loadAnthropicOAuthRefreshToken() -> String? {
|
||||
let url = self.oauthURL()
|
||||
guard let storage = self.loadStorage(at: url) else { return nil }
|
||||
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
||||
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String { return value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
return json as? [String: Any]
|
||||
}
|
||||
|
||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||
let url = self.oauthURL()
|
||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
||||
|
||||
var updated = existing
|
||||
updated[self.providerKey] = [
|
||||
"type": creds.type,
|
||||
"refresh": creds.refresh,
|
||||
"access": creds.access,
|
||||
"expires": creds.expires,
|
||||
]
|
||||
|
||||
try self.saveStorage(updated)
|
||||
}
|
||||
|
||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||
let dir = self.oauthDir()
|
||||
try FileManager().createDirectory(
|
||||
at: dir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
|
||||
let url = self.oauthURL()
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: storage,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
fileprivate func base64URLEncodedString() -> String {
|
||||
self.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum AnthropicOAuthCodeState {
|
||||
struct Parsed: Equatable {
|
||||
let code: String
|
||||
let state: String
|
||||
}
|
||||
|
||||
/// Extracts a `code#state` payload from arbitrary text.
|
||||
///
|
||||
/// Supports:
|
||||
/// - raw `code#state`
|
||||
/// - OAuth callback URLs containing `code=` and `state=` query params
|
||||
/// - surrounding text/backticks from instructions pages
|
||||
static func extract(from raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
|
||||
if text.isEmpty { return nil }
|
||||
|
||||
if let fromURL = self.extractFromURL(text) { return fromURL }
|
||||
if let fromToken = self.extractFromToken(text) { return fromToken }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parse(from raw: String) -> Parsed? {
|
||||
guard let extracted = self.extract(from: raw) else { return nil }
|
||||
let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
|
||||
let code = parts.first ?? ""
|
||||
let state = parts.count > 1 ? parts[1] : ""
|
||||
guard !code.isEmpty, !state.isEmpty else { return nil }
|
||||
return Parsed(code: code, state: state)
|
||||
}
|
||||
|
||||
private static func extractFromURL(_ text: String) -> String? {
|
||||
// Users might copy the callback URL from the browser address bar.
|
||||
guard let components = URLComponents(string: text),
|
||||
let items = components.queryItems,
|
||||
let code = items.first(where: { $0.name == "code" })?.value,
|
||||
let state = items.first(where: { $0.name == "state" })?.value,
|
||||
!code.isEmpty, !state.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return "\(code)#\(state)"
|
||||
}
|
||||
|
||||
private static func extractFromToken(_ text: String) -> String? {
|
||||
// Base64url-ish tokens; keep this fairly strict to avoid false positives.
|
||||
let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = re.firstMatch(in: text, range: range),
|
||||
match.numberOfRanges == 3,
|
||||
let full = Range(match.range(at: 0), in: text)
|
||||
else { return nil }
|
||||
|
||||
return String(text[full])
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
/// Keep in sync with src/infra/host-env-security-policy.json.
|
||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
private static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"SHELL",
|
||||
"SHELLOPTS",
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
]
|
||||
|
||||
private static let blockedPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
"BASH_FUNC_",
|
||||
]
|
||||
private static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"ZDOTDIR",
|
||||
]
|
||||
private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys
|
||||
private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes
|
||||
private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys
|
||||
private static let shellWrapperAllowedOverrideKeys: Set<String> = [
|
||||
"TERM",
|
||||
"LANG",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Generated file. Do not edit directly.
|
||||
// Source: src/infra/host-env-security-policy.json
|
||||
// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSecurityPolicy {
|
||||
static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"GIT_EXTERNAL_DIFF",
|
||||
"SHELL",
|
||||
"SHELLOPTS",
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE"
|
||||
]
|
||||
|
||||
static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"ZDOTDIR"
|
||||
]
|
||||
|
||||
static let blockedPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
"BASH_FUNC_"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawDiscovery
|
||||
@@ -69,22 +68,6 @@ struct OnboardingView: View {
|
||||
@State var workspacePath: String = ""
|
||||
@State var workspaceStatus: String?
|
||||
@State var workspaceApplying = false
|
||||
@State var anthropicAuthPKCE: AnthropicOAuth.PKCE?
|
||||
@State var anthropicAuthCode: String = ""
|
||||
@State var anthropicAuthStatus: String?
|
||||
@State var anthropicAuthBusy = false
|
||||
@State var anthropicAuthConnected = false
|
||||
@State var anthropicAuthVerifying = false
|
||||
@State var anthropicAuthVerified = false
|
||||
@State var anthropicAuthVerificationAttempted = false
|
||||
@State var anthropicAuthVerificationFailed = false
|
||||
@State var anthropicAuthVerifiedAt: Date?
|
||||
@State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||
@State var anthropicAuthAutoDetectClipboard = true
|
||||
@State var anthropicAuthAutoConnectClipboard = true
|
||||
@State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
@State var monitoringAuth = false
|
||||
@State var authMonitorTask: Task<Void, Never>?
|
||||
@State var needsBootstrap = false
|
||||
@State var didAutoKickoff = false
|
||||
@State var showAdvancedConnection = false
|
||||
@@ -104,19 +87,9 @@ struct OnboardingView: View {
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let anthropicAuthPageIndex = 2
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
let permissionsPageIndex = 5
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
|
||||
@@ -78,70 +78,4 @@ extension OnboardingView {
|
||||
self.copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
|
||||
}
|
||||
|
||||
func startAnthropicOAuth() {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.anthropicAuthPKCE = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finishAnthropicOAuth() async {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
|
||||
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func pollAnthropicClipboardIfNeeded() {
|
||||
guard self.currentPage == self.anthropicAuthPageIndex else { return }
|
||||
guard self.anthropicAuthPKCE != nil else { return }
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard self.anthropicAuthAutoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
|
||||
self.anthropicAuthLastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.anthropicAuthCode != next {
|
||||
self.anthropicAuthCode = next
|
||||
self.anthropicAuthStatus = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.anthropicAuthAutoConnectClipboard else { return }
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ extension OnboardingView {
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
self.stopAuthMonitoring()
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
}
|
||||
.task {
|
||||
@@ -61,7 +60,6 @@ extension OnboardingView {
|
||||
self.refreshCLIStatus()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ extension OnboardingView {
|
||||
func updateMonitoring(for pageIndex: Int) {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.updateAuthMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
}
|
||||
|
||||
@@ -63,33 +62,6 @@ extension OnboardingView {
|
||||
self.gatewayDiscovery.stop()
|
||||
}
|
||||
|
||||
func updateAuthMonitoring(for pageIndex: Int) {
|
||||
let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
|
||||
if shouldMonitor, !self.monitoringAuth {
|
||||
self.monitoringAuth = true
|
||||
self.startAuthMonitoring()
|
||||
} else if !shouldMonitor, self.monitoringAuth {
|
||||
self.stopAuthMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
func startAuthMonitoring() {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run { self.refreshAnthropicOAuthStatus() }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAuthMonitoring() {
|
||||
self.monitoringAuth = false
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = nil
|
||||
}
|
||||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
self.installingCLI = true
|
||||
@@ -125,54 +97,4 @@ extension OnboardingView {
|
||||
expected: expected)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAnthropicOAuthStatus() {
|
||||
_ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let previous = self.anthropicAuthDetectedStatus
|
||||
let status = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
self.anthropicAuthConnected = status.isConnected
|
||||
|
||||
if previous != status {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerificationAttempted = false
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
guard self.anthropicAuthDetectedStatus.isConnected else { return }
|
||||
if self.anthropicAuthVerified, !force { return }
|
||||
if self.anthropicAuthVerifying { return }
|
||||
if self.anthropicAuthVerificationAttempted, !force { return }
|
||||
|
||||
self.anthropicAuthVerificationAttempted = true
|
||||
self.anthropicAuthVerifying = true
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
defer { self.anthropicAuthVerifying = false }
|
||||
|
||||
guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
|
||||
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(updated)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthVerified = true
|
||||
self.anthropicAuthVerifiedAt = Date()
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthStatus = "OAuth detected and verified."
|
||||
} catch {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ extension OnboardingView {
|
||||
self.welcomePage()
|
||||
case 1:
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.anthropicAuthPage()
|
||||
case 3:
|
||||
self.wizardPage()
|
||||
case 5:
|
||||
@@ -340,170 +338,6 @@ extension OnboardingView {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func anthropicAuthPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Connect Claude")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Give your model the token it needs!")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 12, padding: 16) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(
|
||||
self.anthropicAuthConnected
|
||||
? (self.anthropicAuthVerified
|
||||
? "Claude connected (OAuth) — verified"
|
||||
: "Claude connected (OAuth)")
|
||||
: "Not connected yet")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
|
||||
Text("Verifying OAuth…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if !self.anthropicAuthConnected {
|
||||
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
|
||||
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets OpenClaw use Claude immediately. Credentials are stored at " +
|
||||
"`~/.openclaw/credentials/oauth.json` (owner-only).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(OpenClawOAuthStore.oauthURL().path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Refresh") {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if !self.anthropicAuthVerified {
|
||||
if self.anthropicAuthConnected {
|
||||
Button("Verify") {
|
||||
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
|
||||
if self.anthropicAuthVerificationFailed {
|
||||
Button("Re-auth (OAuth)") {
|
||||
self.startAnthropicOAuth()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
self.startAnthropicOAuth()
|
||||
} label: {
|
||||
if self.anthropicAuthBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Open Claude sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste the `code#state` value")
|
||||
.font(.headline)
|
||||
TextField("code#state", text: self.$anthropicAuthCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(
|
||||
self.anthropicAuthBusy ||
|
||||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollAnthropicClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 12) {
|
||||
Text("API key (advanced)")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.shadow(color: .clear, radius: 0)
|
||||
.background(Color.clear)
|
||||
|
||||
if let status = self.anthropicAuthStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await self.verifyAnthropicOAuthIfNeeded() }
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
|
||||
@@ -37,18 +37,9 @@ extension OnboardingView {
|
||||
view.cliStatus = "Installed"
|
||||
view.workspacePath = "/tmp/openclaw"
|
||||
view.workspaceStatus = "Saved workspace"
|
||||
view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
||||
view.anthropicAuthCode = "code#state"
|
||||
view.anthropicAuthStatus = "Connected"
|
||||
view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
|
||||
view.anthropicAuthConnected = true
|
||||
view.anthropicAuthAutoDetectClipboard = false
|
||||
view.anthropicAuthAutoConnectClipboard = false
|
||||
|
||||
view.state.connectionMode = .local
|
||||
_ = view.welcomePage()
|
||||
_ = view.connectionPage()
|
||||
_ = view.anthropicAuthPage()
|
||||
_ = view.wizardPage()
|
||||
_ = view.permissionsPage()
|
||||
_ = view.cliPage()
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.24</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602230</string>
|
||||
<string>202602260</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -280,19 +280,17 @@ actor GatewayWizardClient {
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: identity.deviceId,
|
||||
clientId: clientId,
|
||||
clientMode: clientMode,
|
||||
role: role,
|
||||
scopes: scopes,
|
||||
signedAtMs: signedAtMs,
|
||||
token: self.token,
|
||||
nonce: connectNonce,
|
||||
platform: platform,
|
||||
deviceFamily: "Mac")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
|
||||
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let agentid: String?
|
||||
public let threadid: String?
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
agentid: String?,
|
||||
threadid: String?,
|
||||
sessionkey: String?,
|
||||
idempotencykey: String)
|
||||
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.agentid = agentid
|
||||
self.threadid = threadid
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case agentid = "agentId"
|
||||
case threadid = "threadId"
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -2805,6 +2809,9 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplanv2: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
@@ -2813,12 +2820,19 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let turnsourcechannel: AnyCodable?
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2827,11 +2841,18 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
turnsourcechannel: AnyCodable?,
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplanv2 = systemrunplanv2
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
self.host = host
|
||||
@@ -2840,6 +2861,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
@@ -2847,6 +2872,9 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2855,6 +2883,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
@@ -2968,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let publickey: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let devicefamily: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let role: String?
|
||||
@@ -2984,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -2999,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
self.publickey = publickey
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.devicefamily = devicefamily
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.role = role
|
||||
@@ -3016,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct AnthropicAuthControlsSmokeTests {
|
||||
@Test func anthropicAuthControlsBuildsBodyLocal() {
|
||||
let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
||||
let view = AnthropicAuthControls(
|
||||
connectionMode: .local,
|
||||
oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
|
||||
pkce: pkce,
|
||||
code: "code#state",
|
||||
statusText: "Detected code",
|
||||
autoDetectClipboard: false,
|
||||
autoConnectClipboard: false)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func anthropicAuthControlsBuildsBodyRemote() {
|
||||
let view = AnthropicAuthControls(
|
||||
connectionMode: .remote,
|
||||
oauthStatus: .missingFile,
|
||||
pkce: nil,
|
||||
code: "",
|
||||
statusText: nil)
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||
let payload = [
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: oauthFile, options: [.atomic])
|
||||
|
||||
let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile)
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-ignored",
|
||||
], oauthStatus: status)
|
||||
#expect(mode == .oauthFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsOAuthEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_OAUTH_TOKEN": "token",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .oauthEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsAPIKeyEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-key",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .apiKeyEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingWhenNothingConfigured() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct AnthropicOAuthCodeStateTests {
|
||||
@Test
|
||||
func parsesRawToken() {
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsesBacktickedToken() {
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsesCallbackURL() {
|
||||
let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func extractsFromSurroundingText() {
|
||||
let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct OpenClawOAuthStoreTests {
|
||||
@Test
|
||||
func returnsMissingWhenFileAbsent() {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)")
|
||||
.appendingPathComponent("oauth.json")
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForOpenClawOAuthDir() throws {
|
||||
let key = "OPENCLAW_OAUTH_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
#expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsPiFormatTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsTokenKeyVariants() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh_token": "r1",
|
||||
"access_token": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingProviderEntry() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"other": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "",
|
||||
"access": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
||||
}
|
||||
|
||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let url = dir.appendingPathComponent("oauth.json")
|
||||
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
public enum GatewayDeviceAuthPayload {
|
||||
public static func buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: [String],
|
||||
signedAtMs: Int,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
platform: String?,
|
||||
deviceFamily: String?) -> String
|
||||
{
|
||||
let scopeString = scopes.joined(separator: ",")
|
||||
let authToken = token ?? ""
|
||||
let normalizedPlatform = normalizeMetadataField(platform)
|
||||
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
|
||||
return [
|
||||
"v3",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
String(signedAtMs),
|
||||
authToken,
|
||||
nonce,
|
||||
normalizedPlatform,
|
||||
normalizedDeviceFamily,
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
static func normalizeMetadataField(_ value: String?) -> String {
|
||||
guard let value else { return "" }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return ""
|
||||
}
|
||||
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
|
||||
// lowercase ASCII A-Z only for auth payload metadata fields.
|
||||
var output = String()
|
||||
output.reserveCapacity(trimmed.count)
|
||||
for scalar in trimmed.unicodeScalars {
|
||||
let codePoint = scalar.value
|
||||
if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) {
|
||||
output.unicodeScalars.append(lowered)
|
||||
} else {
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -398,20 +398,18 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity?.deviceId ?? "",
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
authToken ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if includeDeviceIdentity, let identity {
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: identity.deviceId,
|
||||
clientId: clientId,
|
||||
clientMode: clientMode,
|
||||
role: role,
|
||||
scopes: scopes,
|
||||
signedAtMs: signedAtMs,
|
||||
token: authToken,
|
||||
nonce: connectNonce,
|
||||
platform: platform,
|
||||
deviceFamily: InstanceIdentity.deviceFamily)
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
let device: [String: ProtoAnyCodable] = [
|
||||
|
||||
@@ -408,6 +408,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let agentid: String?
|
||||
public let threadid: String?
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
@@ -420,6 +421,7 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
agentid: String?,
|
||||
threadid: String?,
|
||||
sessionkey: String?,
|
||||
idempotencykey: String)
|
||||
@@ -431,6 +433,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.agentid = agentid
|
||||
self.threadid = threadid
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -444,6 +447,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case agentid = "agentId"
|
||||
case threadid = "threadId"
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -2805,6 +2809,9 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplanv2: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
@@ -2813,12 +2820,19 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let turnsourcechannel: AnyCodable?
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2827,11 +2841,18 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
turnsourcechannel: AnyCodable?,
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplanv2 = systemrunplanv2
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
self.host = host
|
||||
@@ -2840,6 +2861,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
@@ -2847,6 +2872,9 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2855,6 +2883,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
@@ -2968,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let publickey: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let devicefamily: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let role: String?
|
||||
@@ -2984,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -2999,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
self.publickey = publickey
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.devicefamily = devicefamily
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.role = role
|
||||
@@ -3016,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite("DeviceAuthPayload")
|
||||
struct DeviceAuthPayloadTests {
|
||||
@Test("builds canonical v3 payload vector")
|
||||
func buildsCanonicalV3PayloadVector() {
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: "dev-1",
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "ui",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
signedAtMs: 1_700_000_000_000,
|
||||
token: "tok-123",
|
||||
nonce: "nonce-abc",
|
||||
platform: " IOS ",
|
||||
deviceFamily: " iPhone ")
|
||||
#expect(
|
||||
payload
|
||||
== "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
|
||||
}
|
||||
|
||||
@Test("normalizes metadata with ASCII-only lowercase")
|
||||
func normalizesMetadataWithAsciiLowercase() {
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,66 @@ if (modalElement && Array.isArray(modalElement.styles)) {
|
||||
modalElement.styles = [...modalElement.styles, modalStyles];
|
||||
}
|
||||
|
||||
const appendComponentStyles = (tagName, extraStyles) => {
|
||||
const component = customElements.get(tagName);
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = component.styles;
|
||||
if (!current) {
|
||||
component.styles = [extraStyles];
|
||||
return;
|
||||
}
|
||||
|
||||
component.styles = Array.isArray(current) ? [...current, extraStyles] : [current, extraStyles];
|
||||
};
|
||||
|
||||
appendComponentStyles(
|
||||
"a2ui-row",
|
||||
css`
|
||||
@media (max-width: 860px) {
|
||||
section {
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
flex: 1 1 100%;
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
appendComponentStyles(
|
||||
"a2ui-column",
|
||||
css`
|
||||
:host {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
appendComponentStyles(
|
||||
"a2ui-card",
|
||||
css`
|
||||
:host {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const emptyClasses = () => ({});
|
||||
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ const BADGE = {
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
let relayGatewayToken = ''
|
||||
/** @type {string|null} */
|
||||
let relayConnectRequestId = null
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
@@ -143,6 +146,13 @@ async function ensureRelayConnection() {
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
relayWs = ws
|
||||
relayGatewayToken = gatewayToken
|
||||
// Bind message handler before open so an immediate first frame (for example
|
||||
// gateway connect.challenge) cannot be missed.
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== relayWs) return
|
||||
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
|
||||
@@ -162,10 +172,6 @@ async function ensureRelayConnection() {
|
||||
|
||||
// Bind permanent handlers. Guard against stale socket: if this WS was
|
||||
// replaced before its close fires, the handler is a no-op.
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== relayWs) return
|
||||
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (ws !== relayWs) return
|
||||
onRelayClosed('closed')
|
||||
@@ -188,6 +194,8 @@ async function ensureRelayConnection() {
|
||||
// Debugger sessions are kept alive so they survive transient WS drops.
|
||||
function onRelayClosed(reason) {
|
||||
relayWs = null
|
||||
relayGatewayToken = ''
|
||||
relayConnectRequestId = null
|
||||
|
||||
for (const [id, p] of pending.entries()) {
|
||||
pending.delete(id)
|
||||
@@ -308,6 +316,33 @@ function sendToRelay(payload) {
|
||||
ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function ensureGatewayHandshakeStarted(payload) {
|
||||
if (relayConnectRequestId) return
|
||||
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
|
||||
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
sendToRelay({
|
||||
type: 'req',
|
||||
id: relayConnectRequestId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'chrome-relay-extension',
|
||||
version: '1.0.0',
|
||||
platform: 'chrome-extension',
|
||||
mode: 'webchat',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
nonce: nonce || undefined,
|
||||
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeOpenHelpOnce() {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
|
||||
@@ -349,6 +384,33 @@ async function onRelayMessage(text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
try {
|
||||
ensureGatewayHandshakeStarted(msg.payload)
|
||||
} catch (err) {
|
||||
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
|
||||
relayConnectRequestId = null
|
||||
const ws = relayWs
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1008, 'gateway connect failed')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
|
||||
relayConnectRequestId = null
|
||||
if (!msg.ok) {
|
||||
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
|
||||
console.warn('gateway connect handshake rejected', String(detail))
|
||||
const ws = relayWs
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1008, 'gateway connect failed')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.method === 'ping') {
|
||||
try {
|
||||
sendToRelay({ method: 'pong' })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user