mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 16:41:22 +08:00
Compare commits
642 Commits
fix/browse
...
tmp-30439b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a6dbd2e9 | ||
|
|
30ec0139a2 | ||
|
|
051fba6995 | ||
|
|
28d0576fd1 | ||
|
|
c9f0d6ac8e | ||
|
|
ede944371f | ||
|
|
61ef76edb5 | ||
|
|
92199ac129 | ||
|
|
e1bf9591c3 | ||
|
|
2a252a14cc | ||
|
|
77ccd35e5e | ||
|
|
8e69fd80e0 | ||
|
|
6532757cdf | ||
|
|
0202d79df4 | ||
|
|
c13b35b83d | ||
|
|
aafc4d56e3 | ||
|
|
00dcd931cb | ||
|
|
6398a0ba8f | ||
|
|
8a4d8c889c | ||
|
|
d446722f2f | ||
|
|
edd9319552 | ||
|
|
072e1e9e38 | ||
|
|
95db5bb5e8 | ||
|
|
0fa5d6ed2e | ||
|
|
20467d987d | ||
|
|
08c35eb13f | ||
|
|
904016b7de | ||
|
|
08f8aea32e | ||
|
|
22666034a0 | ||
|
|
1a42ea3abf | ||
|
|
030565b18c | ||
|
|
6ea3a47dae | ||
|
|
cdbed3c9b1 | ||
|
|
355b4c62bc | ||
|
|
6bea38b21f | ||
|
|
342bf4838e | ||
|
|
67b98139b9 | ||
|
|
9e4a366ee6 | ||
|
|
493ebb915b | ||
|
|
166ae8f002 | ||
|
|
efd303dbc4 | ||
|
|
6210d2e238 | ||
|
|
c869ca4bbf | ||
|
|
c14c17403e | ||
|
|
e95f96f77a | ||
|
|
9aba8422ca | ||
|
|
25b731c34a | ||
|
|
0a67033fe3 | ||
|
|
e4e5d9c98c | ||
|
|
15c1c93a95 | ||
|
|
9c03f8be08 | ||
|
|
31c4722e90 | ||
|
|
48d369749c | ||
|
|
e6e3a7b497 | ||
|
|
ef9085927b | ||
|
|
2a381e6d7b | ||
|
|
f64d25bd3e | ||
|
|
bbab94c1fe | ||
|
|
e482da6682 | ||
|
|
fd341d0d3f | ||
|
|
b0c7f1ebe2 | ||
|
|
684ac44b71 | ||
|
|
8eac33d4e0 | ||
|
|
601d1ccd24 | ||
|
|
5850045df6 | ||
|
|
1da7906a5d | ||
|
|
a1a8ec6870 | ||
|
|
00d2df46c7 | ||
|
|
40fda40aa7 | ||
|
|
17bae93680 | ||
|
|
cc0806dfab | ||
|
|
4f8c49e85b | ||
|
|
0c8fa63b93 | ||
|
|
0a182bb4d1 | ||
|
|
042d06a19b | ||
|
|
666a4763ee | ||
|
|
b3990ad58a | ||
|
|
2dcd2f9094 | ||
|
|
e54ddf6161 | ||
|
|
5b562e96cb | ||
|
|
76ed274aad | ||
|
|
3b2ed8fe6f | ||
|
|
8247c25a32 | ||
|
|
60f8e832e0 | ||
|
|
a262a3ea08 | ||
|
|
a25a73e707 | ||
|
|
8629b996a1 | ||
|
|
3efd190aca | ||
|
|
4b2e35ab95 | ||
|
|
7f4d1b7531 | ||
|
|
5b64b96c6c | ||
|
|
60330e011b | ||
|
|
a5f0a9240f | ||
|
|
aaa7de45fa | ||
|
|
ffe1937b92 | ||
|
|
9670ccfc41 | ||
|
|
313a655d13 | ||
|
|
e70fc5eb62 | ||
|
|
757e09fe43 | ||
|
|
a779c2ca6a | ||
|
|
dba039f016 | ||
|
|
70ee256ae0 | ||
|
|
e076665e5e | ||
|
|
65e13c7b6e | ||
|
|
f1354869bd | ||
|
|
ca770622b3 | ||
|
|
949200d7cb | ||
|
|
68a8a98ab7 | ||
|
|
f8459ef46c | ||
|
|
f53ea0b74b | ||
|
|
feefedfb83 | ||
|
|
710004e011 | ||
|
|
bfeadb80b6 | ||
|
|
a6a742f3d0 | ||
|
|
e7cd4bf1bd | ||
|
|
e07c51b045 | ||
|
|
155118751f | ||
|
|
1636f7ff5f | ||
|
|
654f63e8f8 | ||
|
|
b9aa2d436b | ||
|
|
6a1eedf10b | ||
|
|
ed86252aa5 | ||
|
|
a95c8077e8 | ||
|
|
81ca309ee6 | ||
|
|
250f9e15f5 | ||
|
|
085c23ce5a | ||
|
|
e1a9ba8400 | ||
|
|
c823a85302 | ||
|
|
4320cde91d | ||
|
|
da80e22d89 | ||
|
|
ac3e1e769b | ||
|
|
81d600d55e | ||
|
|
c80f34f0e0 | ||
|
|
4274374297 | ||
|
|
cef5fae0a2 | ||
|
|
b81e1b902d | ||
|
|
dc816b84ea | ||
|
|
f696b64b51 | ||
|
|
412eabc42b | ||
|
|
68832f203e | ||
|
|
fbd832d64f | ||
|
|
9e727893ff | ||
|
|
3a68c56264 | ||
|
|
58659b931b | ||
|
|
9005e8bc0a | ||
|
|
0c0f556927 | ||
|
|
0eac494db7 | ||
|
|
84d0a794ec | ||
|
|
235ed71e94 | ||
|
|
4f9daf9821 | ||
|
|
8884f99c92 | ||
|
|
f66f563c1a | ||
|
|
43cad8268d | ||
|
|
1c8ae978d2 | ||
|
|
53d10f8688 | ||
|
|
8e6b3ade3e | ||
|
|
41cc46bbb4 | ||
|
|
d729ab2150 | ||
|
|
ee96e1751e | ||
|
|
a62d55b283 | ||
|
|
75596e9370 | ||
|
|
12c1257023 | ||
|
|
881ac62005 | ||
|
|
ee03ade0d6 | ||
|
|
50e2674dfc | ||
|
|
577becf1ad | ||
|
|
5056b6438d | ||
|
|
a0d2f6e4fe | ||
|
|
23f434f98d | ||
|
|
4614222572 | ||
|
|
577f2fa540 | ||
|
|
9e6e7a3d69 | ||
|
|
13bb80df9d | ||
|
|
912ddba81e | ||
|
|
6c5633598e | ||
|
|
ccb415b69a | ||
|
|
bed1cb9600 | ||
|
|
6a80e9db05 | ||
|
|
51bccaf988 | ||
|
|
b99666a47a | ||
|
|
8e48520d74 | ||
|
|
4c43fccb3e | ||
|
|
738dd9aa42 | ||
|
|
eb20793550 | ||
|
|
0f5348acb2 | ||
|
|
d1615eb35f | ||
|
|
bc9f357ad7 | ||
|
|
002539c01e | ||
|
|
4ab13eca4d | ||
|
|
eee870576d | ||
|
|
063c4f00ea | ||
|
|
a374325fc2 | ||
|
|
8da86f6995 | ||
|
|
7ced38b5ef | ||
|
|
38da2d076c | ||
|
|
dcd19da425 | ||
|
|
3be1343e00 | ||
|
|
687f5779d1 | ||
|
|
4fc7ecf088 | ||
|
|
e4d22fb07a | ||
|
|
3a93a7bb1e | ||
|
|
c89836a251 | ||
|
|
c1428e8df9 | ||
|
|
e6049345db | ||
|
|
e7cafed424 | ||
|
|
94a5d28d26 | ||
|
|
79f818e8a2 | ||
|
|
125ea585dd | ||
|
|
266084f4c8 | ||
|
|
4b027927cf | ||
|
|
23c6e9836e | ||
|
|
c161e141f3 | ||
|
|
bdd59e0149 | ||
|
|
08ea7f0cf6 | ||
|
|
86e4f3e7e2 | ||
|
|
8c4071f36a | ||
|
|
e4b4fd5ce8 | ||
|
|
7aa9267d00 | ||
|
|
ba0aa3cfae | ||
|
|
b0a73ae773 | ||
|
|
07da843378 | ||
|
|
153adc4c8f | ||
|
|
86a91cc01a | ||
|
|
3c4cdf72c9 | ||
|
|
22653c0e27 | ||
|
|
af12e7bdec | ||
|
|
5e061fd8b9 | ||
|
|
5d7314db22 | ||
|
|
907c09e1d5 | ||
|
|
b12c909ea2 | ||
|
|
f81c2e75d2 | ||
|
|
ac5d7ee4cd | ||
|
|
18033d3962 | ||
|
|
8292401719 | ||
|
|
4664d13857 | ||
|
|
053e5eb506 | ||
|
|
6c08652c8d | ||
|
|
ca31683ca3 | ||
|
|
63e607db9b | ||
|
|
f4538b22f7 | ||
|
|
be73eb28b3 | ||
|
|
dd2fcade3e | ||
|
|
43c57005a6 | ||
|
|
c8b958e573 | ||
|
|
acd6ddb829 | ||
|
|
5232f96d59 | ||
|
|
4324d84edd | ||
|
|
c3a1fe01ae | ||
|
|
4a82012461 | ||
|
|
4e2efaf659 | ||
|
|
79fcc8404e | ||
|
|
752398a6ba | ||
|
|
54ed2efc20 | ||
|
|
9cfc630be9 | ||
|
|
cf3e4d2aef | ||
|
|
2466a9bb13 | ||
|
|
f88bc09f85 | ||
|
|
d669b27a45 | ||
|
|
bdc355d0b0 | ||
|
|
9cae5107d1 | ||
|
|
921ebfb25e | ||
|
|
134296276a | ||
|
|
11d34700c0 | ||
|
|
a179a0d371 | ||
|
|
2c5b898eea | ||
|
|
eddaf19478 | ||
|
|
2a409bbba0 | ||
|
|
39a45121d9 | ||
|
|
b9e07ad7b4 | ||
|
|
93ac2b43fb | ||
|
|
746688ddc9 | ||
|
|
e0571399ac | ||
|
|
6dbbc58a8d | ||
|
|
46da76e267 | ||
|
|
a28a4b1b61 | ||
|
|
43ddb41354 | ||
|
|
949faff5ce | ||
|
|
a54b85822c | ||
|
|
bd78a74298 | ||
|
|
dfbdab5a29 | ||
|
|
9ae94390b9 | ||
|
|
265b22c401 | ||
|
|
53d6e07a60 | ||
|
|
0f36ee5a2e | ||
|
|
3aad6c8bdb | ||
|
|
b3f60a68a0 | ||
|
|
4ba0a4d4fb | ||
|
|
ff563eef0f | ||
|
|
201c6252ed | ||
|
|
08f98ac1c9 | ||
|
|
6f63fc288a | ||
|
|
0d672e43b9 | ||
|
|
759a0fc1b2 | ||
|
|
9c2f7e2a9d | ||
|
|
348a7dd5b3 | ||
|
|
7f9274b71d | ||
|
|
4da4cc94c1 | ||
|
|
e3ba59dc71 | ||
|
|
59fd394bfe | ||
|
|
ba430cc65b | ||
|
|
ccca99c472 | ||
|
|
a583261775 | ||
|
|
930841cd7c | ||
|
|
587790e84a | ||
|
|
4748ba491d | ||
|
|
68db055f1a | ||
|
|
f0fcecd7c1 | ||
|
|
266d320062 | ||
|
|
4637b90c07 | ||
|
|
f902697bd5 | ||
|
|
8c98cf05b2 | ||
|
|
d0ca02e963 | ||
|
|
cb6f993b4c | ||
|
|
98e30dc2a3 | ||
|
|
f107347608 | ||
|
|
5784963608 | ||
|
|
0cc46589ac | ||
|
|
29a55948d6 | ||
|
|
645d963954 | ||
|
|
ea3955cd78 | ||
|
|
62a7683ce6 | ||
|
|
5b49cc4129 | ||
|
|
504c1f3607 | ||
|
|
d509a81a12 | ||
|
|
635c78a177 | ||
|
|
310344b6e4 | ||
|
|
54c46b7c8c | ||
|
|
9257dfb5c0 | ||
|
|
0f72000c96 | ||
|
|
3685ccb536 | ||
|
|
c0ce125512 | ||
|
|
39e09273ca | ||
|
|
0abf47cfd5 | ||
|
|
fcb6859784 | ||
|
|
812a996b2f | ||
|
|
1828fdee8b | ||
|
|
612ed5b3e1 | ||
|
|
fca0467082 | ||
|
|
2050fd7539 | ||
|
|
61989091a4 | ||
|
|
c58d2aa99e | ||
|
|
f57b4669e1 | ||
|
|
0929c233d8 | ||
|
|
a623c9c8d2 | ||
|
|
e90429794a | ||
|
|
ea4f5106ea | ||
|
|
9868d5cd8b | ||
|
|
62179c861b | ||
|
|
6dae3c2ca6 | ||
|
|
8624f80649 | ||
|
|
b8863fc813 | ||
|
|
81d215afa7 | ||
|
|
247ff6ff9e | ||
|
|
0331fc5199 | ||
|
|
78d49b4c8e | ||
|
|
deb9560a2b | ||
|
|
be8a5b9d64 | ||
|
|
db25b26e33 | ||
|
|
67a1584e82 | ||
|
|
28c80689d4 | ||
|
|
26db298d3e | ||
|
|
8ae1987f2a | ||
|
|
d7d3416b1d | ||
|
|
e1df1c60b8 | ||
|
|
daa418895e | ||
|
|
3096837238 | ||
|
|
fe9a7c4082 | ||
|
|
2851926314 | ||
|
|
5e2ef0e883 | ||
|
|
e1c8094ad0 | ||
|
|
139271ad5a | ||
|
|
7ae683194f | ||
|
|
5209c48923 | ||
|
|
0740fb83d7 | ||
|
|
4ad49de89d | ||
|
|
9b39490d6a | ||
|
|
1d7b76a90e | ||
|
|
addc619087 | ||
|
|
930e94024a | ||
|
|
727ae469cf | ||
|
|
3daed77ba9 | ||
|
|
72e135083a | ||
|
|
fb92a91ef7 | ||
|
|
fcf3e5b0a0 | ||
|
|
eea081c709 | ||
|
|
548a28a13f | ||
|
|
f0c86039c7 | ||
|
|
44220ef24a | ||
|
|
d6552998e9 | ||
|
|
f5c2be1910 | ||
|
|
150c2093fa | ||
|
|
89e158fc96 | ||
|
|
720e1479b8 | ||
|
|
2258e736b0 | ||
|
|
2fe5620763 | ||
|
|
4ed12c18a0 | ||
|
|
06a3175cd1 | ||
|
|
955768d132 | ||
|
|
978d9ae199 | ||
|
|
f1bf558685 | ||
|
|
5d51e99537 | ||
|
|
d123ade0cb | ||
|
|
f810932859 | ||
|
|
b297bae027 | ||
|
|
7968c0f514 | ||
|
|
3f056a7294 | ||
|
|
cd61edb0f3 | ||
|
|
3899c89805 | ||
|
|
6a16e7bb31 | ||
|
|
a8bcad3db1 | ||
|
|
f1bb26642c | ||
|
|
9d3ccf4754 | ||
|
|
5350f5b035 | ||
|
|
53a2e72fcb | ||
|
|
b0a8909a73 | ||
|
|
8818464f5f | ||
|
|
49cf2bceb6 | ||
|
|
60bf56517f | ||
|
|
8a2273e210 | ||
|
|
0a23739c37 | ||
|
|
4dc55ea88d | ||
|
|
27882dc73e | ||
|
|
e0b1b48be3 | ||
|
|
f29c642c13 | ||
|
|
10f1be1072 | ||
|
|
a5b1e86535 | ||
|
|
8beb048a84 | ||
|
|
b28344eacc | ||
|
|
83698bf13e | ||
|
|
af9edc98e4 | ||
|
|
3e55cc5811 | ||
|
|
84adedd1cb | ||
|
|
0332dce203 | ||
|
|
e4ee585b73 | ||
|
|
08fd579412 | ||
|
|
266f10d47d | ||
|
|
3be12b9fc4 | ||
|
|
7237b4666b | ||
|
|
6e645300a8 | ||
|
|
4f8a54eeaa | ||
|
|
e4cb6a88be | ||
|
|
d9230b13a4 | ||
|
|
6a8d83b6dd | ||
|
|
079bc24613 | ||
|
|
1bc9da8f9e | ||
|
|
18e7938dfd | ||
|
|
943dce37be | ||
|
|
b9e474deb4 | ||
|
|
f75385981a | ||
|
|
81ebe7de46 | ||
|
|
c8ad229776 | ||
|
|
f637cbd246 | ||
|
|
107be4e909 | ||
|
|
aef5355102 | ||
|
|
0e4c24ebe2 | ||
|
|
3f06693e7d | ||
|
|
f53ef73a2b | ||
|
|
8241145ada | ||
|
|
afa7ac1f68 | ||
|
|
2bef2910f1 | ||
|
|
69c39368ec | ||
|
|
4221b5f809 | ||
|
|
da00ead652 | ||
|
|
89669a33bd | ||
|
|
50aa6a43ed | ||
|
|
8090cb4c5e | ||
|
|
36d69d05e2 | ||
|
|
ed51796d97 | ||
|
|
e16d051d9f | ||
|
|
f16ecd1dac | ||
|
|
70a4f25ab1 | ||
|
|
a509154be5 | ||
|
|
5cb2a3aa1b | ||
|
|
32ee2f0109 | ||
|
|
0e755ad99a | ||
|
|
60ef923051 | ||
|
|
56fa05838a | ||
|
|
1725839720 | ||
|
|
ad804b0356 | ||
|
|
bf9585d056 | ||
|
|
fa5e71d1ae | ||
|
|
d17c083803 | ||
|
|
de77497ea8 | ||
|
|
4aa2dc6857 | ||
|
|
db67492a00 | ||
|
|
ee2eaddeb3 | ||
|
|
46d9605ef8 | ||
|
|
12618c333c | ||
|
|
f943c76cde | ||
|
|
3882b8a5be | ||
|
|
8bc80fad47 | ||
|
|
1867611733 | ||
|
|
2916152f83 | ||
|
|
15cf288d73 | ||
|
|
dede4089a6 | ||
|
|
645791c35e | ||
|
|
8da3a9a92d | ||
|
|
6675aacb5e | ||
|
|
62fa65ec85 | ||
|
|
fe807e4bed | ||
|
|
0fe6cf06b2 | ||
|
|
84a88b2ace | ||
|
|
6b317b1f17 | ||
|
|
aae90cb036 | ||
|
|
a7929abad8 | ||
|
|
0fb7add7d6 | ||
|
|
3a35035512 | ||
|
|
256021b8da | ||
|
|
6222d6650b | ||
|
|
8187fbc571 | ||
|
|
4b37b7b6a9 | ||
|
|
d53b24d185 | ||
|
|
34486f8c10 | ||
|
|
9b64ad30c4 | ||
|
|
72adf1e993 | ||
|
|
54eaf17327 | ||
|
|
0896bb09b0 | ||
|
|
6ed00abc1e | ||
|
|
7f6e822526 | ||
|
|
d911b0254d | ||
|
|
cb9374a2a1 | ||
|
|
22ad7523f1 | ||
|
|
e8e673992a | ||
|
|
f5adb66bbc | ||
|
|
2719398dd9 | ||
|
|
22d422a792 | ||
|
|
284f75500c | ||
|
|
1bf08ae7c9 | ||
|
|
bbab0b005e | ||
|
|
8807267bfd | ||
|
|
b8373eaddc | ||
|
|
29f5da5b2a | ||
|
|
d0ec3de588 | ||
|
|
e99b323a6b | ||
|
|
e48513d512 | ||
|
|
c1e0f8cfb1 | ||
|
|
01f1d355a4 | ||
|
|
1f7b3c613d | ||
|
|
c838a4dde0 | ||
|
|
de885d260f | ||
|
|
0f7664fda3 | ||
|
|
adb41e48ae | ||
|
|
fb34c46074 | ||
|
|
120a7abbab | ||
|
|
67609cc16f | ||
|
|
88a0d87490 | ||
|
|
efdba59e49 | ||
|
|
7aa233790b | ||
|
|
9d52dcf1f4 | ||
|
|
6b59c87570 | ||
|
|
d33f24c4e9 | ||
|
|
7bbfb9de5e | ||
|
|
418111adb9 | ||
|
|
7149ba5574 | ||
|
|
035a2dbb40 | ||
|
|
1f68010bd6 | ||
|
|
35e40f1139 | ||
|
|
5c776be60b | ||
|
|
bc50708057 | ||
|
|
e6be26ef1c | ||
|
|
17578d77e1 | ||
|
|
d320b30b9b | ||
|
|
297cca0565 | ||
|
|
1d43202930 | ||
|
|
2c6b078ff0 | ||
|
|
7dad7cc2ca | ||
|
|
5b62d5603d | ||
|
|
c35368c6dd | ||
|
|
22c74d416b | ||
|
|
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 |
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
17
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report a defect or unexpected behavior in OpenClaw.
|
||||
description: Report defects, including regressions, crashes, and behavior bugs.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
@@ -8,6 +8,17 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
label: Bug type
|
||||
description: Choose the category that best matches this report.
|
||||
options:
|
||||
- Regression (worked before, now fails)
|
||||
- Crash (process/app exits or hangs)
|
||||
- Behavior bug (incorrect output/state without crash)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
@@ -91,5 +102,5 @@ body:
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any context that helps triage but does not fit above.
|
||||
placeholder: Regression started after upgrade from <previous-version>; temporary workaround is ...
|
||||
description: Add any context that helps triage but does not fit above. If this is a regression, include the last known good and first known bad versions.
|
||||
placeholder: Last known good version <...>, first known bad version <...>, temporary workaround is ...
|
||||
|
||||
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
@@ -7,6 +7,7 @@ registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
token: ${{secrets.NPM_NPMJS_TOKEN}}
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
@@ -14,9 +15,9 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
@@ -36,9 +37,9 @@ updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
@@ -52,9 +53,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -68,9 +69,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -84,9 +85,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -100,9 +101,9 @@ updates:
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: weekly
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
@@ -118,7 +119,7 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 2
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
|
||||
248
.github/workflows/auto-response.yml
vendored
248
.github/workflows/auto-response.yml
vendored
@@ -3,6 +3,8 @@ name: Auto response
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
@@ -17,13 +19,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const rules = [
|
||||
@@ -42,6 +51,7 @@ jobs:
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
@@ -55,11 +65,186 @@ 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 bugSubtypeLabelSpecs = {
|
||||
regression: {
|
||||
color: "D93F0B",
|
||||
description: "Behavior that previously worked and now fails",
|
||||
},
|
||||
"bug:crash": {
|
||||
color: "B60205",
|
||||
description: "Process/app exits unexpectedly or hangs",
|
||||
},
|
||||
"bug:behavior": {
|
||||
color: "D73A4A",
|
||||
description: "Incorrect behavior without a crash",
|
||||
},
|
||||
};
|
||||
const bugTypeToLabel = {
|
||||
"Regression (worked before, now fails)": "regression",
|
||||
"Crash (process/app exits or hangs)": "bug:crash",
|
||||
"Behavior bug (incorrect output/state without crash)": "bug:behavior",
|
||||
};
|
||||
const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);
|
||||
|
||||
const extractIssueFormValue = (body, field) => {
|
||||
if (!body) {
|
||||
return "";
|
||||
}
|
||||
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(
|
||||
`(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
|
||||
"i",
|
||||
);
|
||||
const match = body.match(regex);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
for (const line of match[1].split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const ensureLabelExists = async (name, color, description) => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const syncBugSubtypeLabel = async (issue, labelSet) => {
|
||||
if (!labelSet.has("bug")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
|
||||
const targetLabel = bugTypeToLabel[selectedBugType];
|
||||
if (!targetLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
|
||||
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
|
||||
|
||||
for (const subtypeLabel of bugSubtypeLabels) {
|
||||
if (subtypeLabel === targetLabel) {
|
||||
continue;
|
||||
}
|
||||
if (!labelSet.has(subtypeLabel)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: subtypeLabel,
|
||||
});
|
||||
labelSet.delete(subtypeLabel);
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelSet.has(targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [targetLabel],
|
||||
});
|
||||
labelSet.add(targetLabel);
|
||||
}
|
||||
};
|
||||
|
||||
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 +257,65 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
await syncBugSubtypeLabel(issue, labelSet);
|
||||
}
|
||||
}
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
@@ -94,7 +338,6 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
@@ -136,7 +379,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({
|
||||
|
||||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -208,10 +208,6 @@ jobs:
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
@@ -224,21 +220,6 @@ jobs:
|
||||
if: matrix.runtime != 'bun' || github.event_name != 'push'
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
|
||||
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
|
||||
|
||||
- name: Upload vitest reports
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
@@ -259,6 +240,9 @@ jobs:
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
|
||||
- name: Strict TS build smoke
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
@@ -404,6 +388,7 @@ jobs:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
@@ -512,28 +497,9 @@ jobs:
|
||||
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"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Summarize slowest tests
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
|
||||
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
|
||||
|
||||
- name: Upload vitest reports
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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
|
||||
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
|
||||
44
.github/workflows/docker-release.yml
vendored
44
.github/workflows/docker-release.yml
vendored
@@ -69,6 +69,27 @@ jobs:
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (amd64)
|
||||
id: labels
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -76,6 +97,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
@@ -128,6 +150,27 @@ jobs:
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (arm64)
|
||||
id: labels
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_SHA}"
|
||||
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
|
||||
version="main"
|
||||
fi
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
fi
|
||||
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
{
|
||||
echo "value<<EOF"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.version=${version}"
|
||||
echo "org.opencontainers.image.created=${created}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -135,6 +178,7 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
|
||||
5
.github/workflows/install-smoke.yml
vendored
5
.github/workflows/install-smoke.yml
vendored
@@ -48,6 +48,11 @@ jobs:
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
|
||||
31
.github/workflows/labeler.yml
vendored
31
.github/workflows/labeler.yml
vendored
@@ -27,18 +27,25 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest) {
|
||||
@@ -127,7 +134,7 @@ jobs:
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.pull_request?.user?.login;
|
||||
if (!login) {
|
||||
@@ -204,13 +211,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
@@ -444,13 +458,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const login = context.payload.issue?.user?.login;
|
||||
if (!login) {
|
||||
|
||||
51
.github/workflows/stale.yml
vendored
51
.github/workflows/stale.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 10000
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
@@ -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).
|
||||
|
||||
455
CHANGELOG.md
455
CHANGELOG.md
@@ -2,80 +2,374 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.26 (Unreleased)
|
||||
## Since v2026.2.26 (Unreleased)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
|
||||
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
|
||||
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674) Thanks @liuweifly.
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Model directives/Auth profiles: split `/model` profile suffixes at the first `@` after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
|
||||
- Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
|
||||
- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
|
||||
- Ollama/Embedded runner base URL precedence: prioritize configured provider `baseUrl` over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
|
||||
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
|
||||
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
|
||||
- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) Thanks @guoqunabc.
|
||||
- Feishu/Zalo runtime logging: replace direct `console.log/error` usage in Feishu typing-indicator paths and Zalo monitor paths with runtime-gated logger calls so verbosity controls are respected while preserving typing backoff behavior. (#18841) Thanks @Clawborn.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
|
||||
- Feishu/Post markdown parsing: parse rich-text `post` payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
|
||||
- Feishu/Post embedded media: extract `media` tags from inbound rich-text (`post`) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.
|
||||
- Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
|
||||
- Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups.<id>.allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.
|
||||
- Feishu/Group wildcard policy fallback: honor `channels.feishu.groups["*"]` when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.
|
||||
- Feishu/Docx append/write ordering: insert converted Docx blocks sequentially (single-block creates) so Feishu append/write preserves markdown block order instead of returning shuffled sections in asynchronous batch inserts. (#26172, #26022) Thanks @echoVic.
|
||||
- Feishu/Docx convert fallback chunking: recursively split oversized markdown chunks (including long no-heading sections) when `document.convert` hits content limits, while keeping fenced-code-aware split boundaries whenever possible. (#14402) Thanks @lml2468.
|
||||
- Feishu/Inbound media regression coverage: add explicit tests for message resource type mapping (`image` stays `image`, non-image maps to `file`) to prevent reintroducing unsupported Feishu `type=audio` fetches. (#16311, #8746) Thanks @Yaxuan42.
|
||||
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
|
||||
- TTS/Voice bubbles: use opus output and enable `audioAsVoice` routing for Feishu and WhatsApp (in addition to Telegram) so supported channels receive voice-bubble playback instead of file-style audio attachments. (#27366) Thanks @smthfoxy.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.
|
||||
- Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
|
||||
- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
|
||||
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
|
||||
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
|
||||
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
|
||||
- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Nodes notification wake flow: enable Android `system.notify` default allowlist, emit `notifications.changed` events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
- Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
|
||||
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
|
||||
- Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42.
|
||||
- Google Chat/Thread replies: set `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` on threaded sends so replies attach to existing threads instead of silently failing thread placement. Landed from contributor PR #30965 by @novan. Thanks @novan.
|
||||
- Mattermost/Private channel policy routing: map Mattermost private channel type `P` to group chat type so `groupPolicy`/`groupAllowFrom` gates apply correctly instead of being treated as open public channels. Landed from contributor PR #30891 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Models/Custom provider keys: trim custom provider map keys during normalization so image-capable models remain discoverable when provider keys are configured with leading/trailing whitespace. Landed from contributor PR #31202 by @stakeswky. Thanks @stakeswky.
|
||||
- Discord/Agent component interactions: accept Components v2 `cid` payloads alongside legacy `componentId`, and safely decode percent-encoded IDs without throwing on malformed `%` sequences. Landed from contributor PR #29013 by @Jacky1n7. Thanks @Jacky1n7.
|
||||
- Matrix/Directory room IDs: preserve original room-ID casing for direct `!roomId` group lookups (without `:server`) so allowlist checks do not fail on case-sensitive IDs. Landed from contributor PR #31201 by @williamos-dev. Thanks @williamos-dev.
|
||||
- Discord/Inbound media fallback: preserve attachment and sticker metadata when Discord CDN fetch/save fails by keeping URL-based media entries in context, with regression coverage for save failures and mixed success/failure ordering. Landed from contributor PR #28906 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Auto-reply/Block reply timeout path: normalize `onBlockReply(...)` execution through `Promise.resolve(...)` before timeout wrapping so mixed sync/async callbacks keep deterministic timeout behavior across strict TypeScript build paths. (#19779) Thanks @dalefrieswthat and @vincentkoc.
|
||||
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @Glucksberg.
|
||||
- Docs/Docker images: clarify the official GHCR image source and tag guidance (`main`, `latest`, `<version>`), and document that `OPENCLAW_IMAGE` skips local image builds but still uses the repo-local compose/setup flow. (#27214, #31180) Fixes #15655. Thanks @ipl31.
|
||||
- Docs/Gateway Docker bind guidance: clarify bridge-network loopback behavior and require bind mode values (`auto`/`loopback`/`lan`/`tailnet`/`custom`) instead of host aliases in `gateway.bind`. (#28001) Thanks @Anandesh-Sharma and @vincentkoc.
|
||||
- Docker/Image base annotations: add OCI labels for base image plus source/documentation/license metadata, include revision/version/created labels in Docker release builds, and document annotation keys/release context in install docs. Fixes #27945. Thanks @vincentkoc.
|
||||
- Agents/Model fallback: classify additional network transport errors (`ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, `ENETRESET`, `EAI_AGAIN`) as failover-worthy so fallback chains advance when primary providers are unreachable. Landed from contributor PR #19077 by @ayanesakura. Thanks @ayanesakura.
|
||||
- Agents/Copilot token refresh: refresh GitHub Copilot runtime API tokens after auth-expiry failures and re-run with the renewed token so long-running embedded/subagent turns do not fail on mid-session 401 expiry. Landed from contributor PR #8805 by @Arthur742Ramos. Thanks @Arthur742Ramos.
|
||||
- Agents/Subagents delivery params: reject unsupported `sessions_spawn` channel-delivery params (`target`, `channel`, `to`, `threadId`, `replyTo`, `transport`) with explicit input errors so delivery intent does not silently leak output to the parent conversation. (#31000)
|
||||
- Telegram/Multi-account fallback isolation: fail closed for non-default Telegram accounts when route resolution falls back to `matchedBy=default`, preventing cross-account DM/session contamination without explicit account bindings. (#31110)
|
||||
- Discord/Allowlist diagnostics: add debug logs for guild/channel allowlist drops so operators can quickly identify ignored inbound messages and required allowlist entries. Landed from contributor PR #30966 by @haosenwang1018. Thanks @haosenwang1018.
|
||||
- Discord/Ack reactions: add Discord-account-level `ackReactionScope` override and support explicit `off`/`none` values in shared config schemas to disable ack reactions per account. Landed from contributor PR #30400 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Discord/Forum thread tags: support `appliedTags` on Discord thread-create actions and map to `applied_tags` for forum/media starter posts, with targeted thread-creation regression coverage. Landed from contributor PR #30358 by @pushkarsingh32. Thanks @pushkarsingh32.
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
|
||||
- Telegram/Proxy dispatcher preservation: preserve proxy-aware global undici dispatcher behavior in Telegram network workarounds so proxy-backed Telegram + model traffic is not broken by dispatcher replacement. Landed from contributor PR #30367 by @Phineas1500. Thanks @Phineas1500.
|
||||
- Telegram/Media fetch IPv4 fallback: retry Telegram media fetches once with IPv4-first dispatcher settings when dual-stack connect errors (`ETIMEDOUT`/`ENETUNREACH`/`EHOSTUNREACH`) occur, improving reliability on broken IPv6 routes. Landed from contributor PR #30554 by @bosuksh. Thanks @bosuksh.
|
||||
- Telegram/DM topic session isolation: scope DM topic thread session keys by chat ID (`<chatId>:<threadId>`) and parse scoped thread IDs in outbound recovery so parallel DMs cannot collide on shared topic IDs. Landed from contributor PR #31064 by @0xble. Thanks @0xble.
|
||||
- Telegram/Group allowlist ordering: evaluate chat allowlist before sender allowlist enforcement so explicitly allowlisted groups are not fail-closed by empty sender allowlists. Landed from contributor PR #30680 by @openperf. Thanks @openperf.
|
||||
- Telegram/Multi-account group isolation: prevent channel-level `groups` config from leaking across Telegram accounts in multi-account setups, avoiding cross-account group routing drops. Landed from contributor PR #30677 by @YUJIE2002. Thanks @YUJIE2002.
|
||||
- Telegram/Voice caption overflow fallback: recover from `sendVoice` caption length errors by re-sending voice without caption and delivering text separately so replies are not lost. Landed from contributor PR #31131 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Telegram/Reply `first` chunking: apply `replyToMode: "first"` reply targets only to the first Telegram text/media/fallback chunk, avoiding multi-chunk over-quoting in split replies. Landed from contributor PR #31077 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
|
||||
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
|
||||
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
|
||||
- Cron/Timer hot-loop guard: enforce a minimum timer re-arm delay when stale past-due jobs would otherwise trigger repeated `setTimeout(0)` loops, preventing event-loop saturation and log-flood behavior. (#29853) Thanks @FlamesCN.
|
||||
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
|
||||
- Onboarding/Docker token parity: use `OPENCLAW_GATEWAY_TOKEN` as the default gateway token in interactive and non-interactive onboarding when `--gateway-token` is not provided, so `docker-setup.sh` token env/config values stay aligned. (#22658) Fixes #22638. Thanks @Clawborn and @vincentkoc.
|
||||
- Slack/Subagent completion delivery: stop forcing bound conversation IDs into `threadId` so Slack completion announces do not send invalid `thread_ts` for DMs/top-level channels. Landed from contributor PR #31105 by @stakeswky. Thanks @stakeswky.
|
||||
- Signal/Loop protection: evaluate own-account detection before sync-message filtering (including UUID-only `accountUuid` configs) so `sentTranscript` sync events cannot bypass loop protection and self-reply loops. Landed from contributor PR #31093 by @kevinWangSheng. Thanks @kevinWangSheng.
|
||||
- Gateway/Control UI origins: support wildcard `"*"` in `gateway.controlUi.allowedOrigins` for trusted remote access setups. Landed from contributor PR #31088 by @frankekn. Thanks @frankekn.
|
||||
- Cron/Isolated CLI timeout ratio: avoid reusing persisted CLI session IDs on fresh isolated cron runs so the fresh watchdog profile is used and jobs do not abort at roughly one-third of configured `timeoutSeconds`. (#30140) Thanks @ningding97.
|
||||
- Cron/Session target guardrail: reject creating or patching `sessionTarget: "main"` cron jobs when `agentId` is not the default agent, preventing invalid cross-agent main-session bindings at write time. (#30217) Thanks @liaosvcaf.
|
||||
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
||||
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
||||
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
||||
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.1`). Thanks @tdjackey for reporting.
|
||||
- Control UI/Cron editor: include `{ mode: "none" }` in `cron.update` patches when editing an existing job and selecting “Result delivery = None (internal)”, so saved jobs no longer keep stale announce delivery mode. Fixes #31075.
|
||||
- Telegram/Restart polling teardown: stop the Telegram bot instance when a polling cycle exits so in-process SIGUSR1 restarts fully tear down old long-poll loops before restart, reducing post-restart `getUpdates` 409 conflict storms. Fixes #31107. Landed from contributor PR #31141 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
- Security/Node metadata policy: harden node platform classification against Unicode confusables and switch unknown platform defaults to a conservative allowlist that excludes `system.run`/`system.which` unless explicitly allowlisted, preventing metadata canonicalization drift from broadening node command permissions. Thanks @tdjackey for reporting.
|
||||
- Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek.
|
||||
- Matrix/Conduit compatibility: avoid blocking startup on non-resolving Matrix sync start, preserve startup error propagation, prevent duplicate monitor listener registration, remove unreliable 2-member DM heuristics, accept `!room` IDs without alias resolution, and add matrix monitor/client regression coverage. Landed from contributor PR #31023 by @efe-arv. Thanks @efe-arv.
|
||||
- Discord/Reconnect watchdog: add a shared armable transport stall-watchdog and wire Discord gateway lifecycle force-stop semantics for silent close/reconnect zombies, with gateway/lifecycle watchdog regression coverage and runtime status liveness updates. Follow-up to contributor PR #31025 by @theotarr and PR #30530 by @liuxiaopai-ai. Thanks @theotarr and @liuxiaopai-ai.
|
||||
- Security/Skills: harden skill installer metadata parsing by rejecting unsafe installer specs (brew/node/go/uv/download) and constrain plugin-declared skill directories to the plugin root (including symlink-escape checks), with regression coverage.
|
||||
- Discord/DM command auth: unify DM allowlist + pairing-store authorization across message preflight and native command interactions so DM command gating is consistent for `open`/`pairing`/`allowlist` policies.
|
||||
- Sessions/Usage accounting: persist `cacheRead`/`cacheWrite` from the latest call snapshot (`lastCallUsage`) instead of accumulated multi-call totals, preventing inflated token/cost reporting in long tool/compaction runs. (#31005)
|
||||
- Sessions/Followup queue: always schedule followup drain even when unexpected runtime exceptions escape `runReplyAgent`, preventing silent stuck followup backlogs after failed turns. (#30627)
|
||||
- Sessions/DM scope migration: when `session.dmScope` is non-`main`, retire stale `agent:*:main` delivery routing metadata once the matching direct-chat peer session is active, preventing duplicate Telegram/DM announce deliveries from legacy main sessions after scope migration. (#31010)
|
||||
- Sessions/Compaction safety: add transcript-size forced pre-compaction memory flush (`agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes`, default 2MB) so long sessions recover without manual transcript deletion when token snapshots are stale. (#30655)
|
||||
- Diagnostics/Stuck session signal: add configurable stuck-session warning threshold via `diagnostics.stuckSessionWarnMs` (default 120000ms) to reduce false-positive warnings on long multi-tool turns. (#31032)
|
||||
- ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob.
|
||||
- Docs/ACP permissions: document the correct `permissionMode` default (`approve-reads`) and clarify non-interactive permission failure behavior/troubleshooting guidance. (#31044) Thanks @barronlroth.
|
||||
- Security/Logging utility hardening: remove `eval`-based command execution from `scripts/clawlog.sh`, switch to argv-safe command construction, and escape predicate literals for user-supplied search/category filters to block local command/predicate injection paths.
|
||||
- Security/ACPX Windows spawn hardening: resolve `.cmd/.bat` wrappers via PATH/PATHEXT and execute unwrapped Node/EXE entrypoints without shell parsing when possible, and enable strict fail-closed handling (`strictWindowsCmdWrapper`) by default for unresolvable wrappers on Windows (with explicit opt-out for compatibility). This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
- Security/Inbound metadata stripping: tighten sentinel matching and JSON-fence validation for inbound metadata stripping so user-authored lookalike lines no longer trigger unintended metadata removal.
|
||||
- Security/Zalo webhook memory hardening: bound webhook security tracking state and normalize security keying to matched webhook paths (excluding attacker query-string churn) to prevent unauthenticated memory growth pressure on reachable webhook endpoints. Thanks @Somet2mes.
|
||||
- Security/Web search citation redirects: enforce strict SSRF defaults for Gemini citation redirect resolution so redirects to localhost/private/internal targets are blocked. Thanks @tdjackey for reporting.
|
||||
- Channels/Command parsing parity: align command-body parsing fields with channel command-gating text for Slack, Signal, Microsoft Teams, Mattermost, and BlueBubbles to avoid mention-strip mismatches and inconsistent command detection.
|
||||
- CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973.
|
||||
- Doctor/macOS state-dir safety: warn when OpenClaw state resolves inside iCloud Drive (`~/Library/Mobile Documents/com~apple~CloudDocs/...`) or `~/Library/CloudStorage/...`, because sync-backed paths can cause slower I/O and lock/sync races. (#31004) Thanks @vincentkoc.
|
||||
- Doctor/Linux state-dir safety: warn when OpenClaw state resolves to an `mmcblk*` mount source (SD or eMMC), because random I/O can be slower and media wear can increase under session and credential writes. (#31033) Thanks @vincentkoc.
|
||||
- CLI/Startup follow-up: add root `--help` fast-path bootstrap bypass with strict root-only matching, lazily resolve CLI channel options only when commands need them, merge build-time startup metadata (`dist/cli-startup-metadata.json`) with runtime catalog discovery so dynamic catalogs are preserved, and add low-power Linux doctor hints for compile-cache placement and respawn tuning. (#30975) Thanks @vincentkoc.
|
||||
- Docker/Compose gateway targeting: run `openclaw-cli` in the `openclaw-gateway` service network namespace, require gateway startup ordering, pin Docker setup to `gateway.mode=local`, sync `gateway.bind` from `OPENCLAW_GATEWAY_BIND`, default optional `CLAUDE_*` compose vars to empty values to reduce automation warning noise, and harden `openclaw-cli` with `cap_drop` (`NET_RAW`, `NET_ADMIN`) + `no-new-privileges`. Docs now call out the shared trust boundary explicitly. (#12504) Thanks @bvanderdrift and @vincentkoc.
|
||||
- Telegram/Outbound API proxy env: keep the Node 22 `autoSelectFamily` global-dispatcher workaround while restoring env-proxy support by using `EnvHttpProxyAgent` so `HTTP_PROXY`/`HTTPS_PROXY` continue to apply to outbound requests. (#26207) Thanks @qsysbio-cjw for reporting and @rylena and @vincentkoc for work.
|
||||
- Browser/Security: fail closed on browser-control auth bootstrap errors; if auto-auth setup fails and no explicit token/password exists, browser control server startup now aborts instead of starting unauthenticated. This ships in the next npm release. Thanks @ijxpwastaken.
|
||||
- Sandbox/noVNC hardening: increase observer password entropy, shorten observer token lifetime, and replace noVNC token redirect with a bootstrap page that keeps credentials out of `Location` query strings and adds strict no-cache/no-referrer headers.
|
||||
- Security/External content marker folding: expand Unicode angle-bracket homoglyph normalization in marker sanitization so additional guillemet, double-angle, tortoise-shell, flattened-parenthesis, and ornamental variants are folded before boundary replacement. (#30951) Thanks @benediktjohannes.
|
||||
- Docs/Slack manifest scopes: add missing DM/group-DM bot scopes (`im:read`, `im:write`, `mpim:read`, `mpim:write`) to the Slack app manifest example so DM setup guidance is complete. (#29999) Thanks @JcMinarro.
|
||||
- Slack/Onboarding token help: update setup text to include the “From manifest” app-creation path and current install wording for obtaining the `xoxb-` bot token. (#30846) Thanks @yzhong52.
|
||||
- Telegram/Thread fallback safety: when Telegram returns `message thread not found`, retry without `message_thread_id` only for DM-thread sends (not forum topics), and suppress first-attempt danger logs when retry succeeds. Landed from contributor PR #30892 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
- Slack/Bot attachment-only messages: when `allowBots: true`, bot messages with empty `text` now include non-forwarded attachment `text`/`fallback` content so webhook alerts are not silently dropped. (#27616) Thanks @lailoo.
|
||||
- Slack/Inbound media auth + HTML guard: keep Slack auth headers on forwarded shared attachment image downloads, and reject login/error HTML payloads (while allowing expected `.html` uploads) when resolving Slack media so auth failures do not silently pass as files. (#18642) Thanks @tumf.
|
||||
- Slack/Security ingress mismatch guard: drop slash-command and interaction payloads when app/team identifiers do not match the active Slack account context (including nested `team.id` interaction payloads), preventing cross-app or cross-workspace payload injection into system-event handling. (#29091) Thanks @Solvely-Colin.
|
||||
- Cron/Failure alerts: add configurable repeated-failure alerting with per-job overrides and Web UI cron editor support (`inherit|disabled|custom` with threshold/cooldown/channel/target fields). (#24789) Thanks @0xbrak.
|
||||
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
|
||||
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
|
||||
- Cron tool/update flat params: recover top-level update patch fields when models omit the `patch` wrapper, and allow flattened update keys through tool input schema validation so `cron.update` no longer fails with `patch required` for valid flat payloads. (#23221)
|
||||
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
|
||||
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
|
||||
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.
|
||||
- Web UI/Control UI WebSocket defaults: include normalized `gateway.controlUi.basePath` (or inferred nested route base path) in the default `gatewayUrl` so first-load dashboard connections work behind path-based reverse proxies. (#30228) Thanks @gittb.
|
||||
- Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin.
|
||||
- Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen.
|
||||
- Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1.
|
||||
- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase.
|
||||
- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
- Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97.
|
||||
- File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg.
|
||||
- Slack/HTTP mode startup: treat Slack HTTP accounts as configured when `botToken` + `signingSecret` are present (without requiring `appToken`) in channel config/runtime status so webhook mode is not silently skipped. (#30567) Thanks @liuxiaopai-ai.
|
||||
- Slack/Transient request errors: classify Slack request-error messages like `Client network socket disconnected before secure TLS connection was established` as transient in unhandled-rejection fatal detection, preventing temporary network drops from crash-looping the gateway. (#23169) Thanks @graysurf.
|
||||
- Slack/Usage footer formatting: wrap session keys in inline code in full response-usage footers so Slack does not parse colon-delimited session segments as emoji shortcodes. (#30258) Thanks @pushkarsingh32.
|
||||
- Slack/Thread session isolation: route channel/group top-level messages into thread-scoped sessions (`:thread:<ts>`) and read inbound `previousTimestamp` from the resolved thread session key, preventing cross-thread context bleed and stale timestamp lookups. (#10686) Thanks @pablohrcarvalho.
|
||||
- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715) Thanks @Glucksberg.
|
||||
- Slack/Legacy streaming config: map boolean `channels.slack.streaming=false` to unified streaming mode `off` (with `nativeStreaming=false`) so legacy configs correctly disable draft preview/native streaming instead of defaulting to `partial`. (#25990) Thanks @chilu18.
|
||||
- Slack/Socket reconnect reliability: reconnect Socket Mode after disconnect/start failures using bounded exponential backoff with abort-aware waits, while preserving clean shutdown behavior and adding disconnect/error helper tests. (#27232) Thanks @pandego.
|
||||
- Memory/QMD update+embed output cap: discard captured stdout for `qmd update` and `qmd embed` runs (while keeping stderr diagnostics) so large index progress output no longer fails sync with `produced too much output` during boot/refresh. (#28900) Thanks @Glucksberg.
|
||||
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
|
||||
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
|
||||
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
|
||||
- TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000.
|
||||
- Agents/Session status: read thinking/verbose/reasoning levels from persisted session state in `session_status` output when resolved levels are not provided, so status reflects runtime toggles correctly. (#30129) Thanks @YuzuruS.
|
||||
- Agents/Tool-name recovery chain: normalize streamed alias/case tool names against the allowed set, preserve whitespace-only streamed placeholders to avoid collapsing to empty names, and repair/guard persisted blank `toolResult.toolName` values from matching tool calls to reduce repeated `Tool not found` loops in long sessions. Landed from contributor PRs #30620 and #30735 by @Sid-Qin, plus #30881 by @liuxiaopai-ai. Thanks @Sid-Qin and @liuxiaopai-ai.
|
||||
- TUI/SIGTERM shutdown: ignore `setRawMode EBADF` teardown errors during `SIGTERM` exit so long-running TUI sessions do not crash on terminal shutdown races, while still rethrowing unrelated stop errors. (#29430) Thanks @Cormazabal.
|
||||
- Memory/Hybrid recall: when strict hybrid scoring yields no hits, preserve keyword-backed matches using a text-weight floor so freshly indexed lexical canaries no longer disappear behind `minScore` filtering. (#29112) Thanks @ceo-nada.
|
||||
- Android/Notifications auth race: return `NOT_AUTHORIZED` when `POST_NOTIFICATIONS` is revoked between authorization precheck and delivery, instead of returning success while dropping the notification. (#30726) Thanks @obviyus.
|
||||
- Cron/Reminder session routing: preserve `job.sessionKey` for `sessionTarget="main"` runs so queued reminders wake and deliver in the originating scoped session/channel instead of being forced to the agent main session.
|
||||
- Cron/Timezone regression guard: add explicit schedule coverage for `0 8 * * *` with `Asia/Shanghai` to ensure `nextRunAtMs` never rolls back to a past year and always advances to the next valid occurrence. (#30351)
|
||||
- Agents/Sessions list transcript paths: resolve `sessions_list` `transcriptPath` via agent-aware session path options and ignore combined-store sentinel paths (`(multiple)`) so listed transcript paths always point to the state directory. (#28379) Thanks @fafuzuoluo.
|
||||
- Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc.
|
||||
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
|
||||
- Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc.
|
||||
- Issues/triage labeling: consolidate bug intake to a single bug issue form with required bug-type classification (regression/crash/behavior), auto-apply matching subtype labels from issue form content, and retire the separate regression template to reduce misfiled issue types and improve queue filtering. Thanks @vincentkoc.
|
||||
- Android/Onboarding + voice reliability: request per-toggle onboarding permissions, update pairing guidance to `openclaw devices list/approve`, restore assistant speech playback in mic capture flow, cancel superseded in-flight speech (mute + per-reply token rotation), and keep `talk.config` loads retryable after transient failures. (#29796) Thanks @obviyus.
|
||||
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
||||
- Config/Doctor group allowlist diagnostics: align `groupPolicy: "allowlist"` warnings with per-channel runtime semantics by excluding Google Chat sender-list checks and by warning when no-fallback channels (for example iMessage) omit `groupAllowFrom`, with regression coverage. (#28477) Thanks @tonydehnke.
|
||||
- Slack/Disabled channel startup: skip Slack monitor socket startup entirely when `channels.slack.enabled=false` (including configs that still contain valid tokens), preventing disabled accounts from opening websocket connections. (#30586) Thanks @liuxiaopai-ai.
|
||||
- Onboarding/Custom providers: use Azure OpenAI-specific verification auth/payload shape (`api-key`, deployment-path chat completions payload) when probing Azure endpoints so valid Azure custom-provider setup no longer fails preflight. (#29421) Thanks @kunalk16.
|
||||
- Feishu/Docx editing tools: add `feishu_doc` positional insert, table row/column operations, table-cell merge, and color-text updates; switch markdown write/append/insert to Descendant API insertion with large-document batching; and harden image uploads for data URI/base64/local-path inputs with strict validation and routing-safe upload metadata. (#29411) Thanks @Elarwei001.
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
|
||||
- Auth/Onboarding: add an explicit account-risk warning and confirmation gate before starting Gemini CLI OAuth, and document the caution in provider docs and the Gemini CLI auth plugin README. (#16683) Thanks @vincentkoc.
|
||||
- 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
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
- FS tools/workspaceOnly: honor `tools.fs.workspaceOnly=false` for host write and edit operations so FS tools can access paths outside the workspace when sandbox is off. (#28822) thanks @lailoo. Fixes #28763. Thanks @cjscld for reporting.
|
||||
- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.
|
||||
- Gemini OAuth/Auth flow: align OAuth project discovery metadata and endpoint fallback handling for Gemini CLI auth, including fallback coverage for environment-provided project IDs. (#16684) Thanks @vincentkoc.
|
||||
- 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) Thanks @steipete.
|
||||
- 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.
|
||||
- Config/Doctor allowlist safety: reject `dmPolicy: "allowlist"` configs with empty `allowFrom`, add Telegram account-level inheritance-aware validation, and teach `openclaw doctor --fix` to restore missing `allowFrom` entries from pairing-store files when present, preventing silent DM drops after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- 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.
|
||||
- Feishu/Merged forward parsing: expand inbound `merge_forward` messages by fetching and formatting API sub-messages in order, so merged forwards provide usable content context instead of only a placeholder line. (#28707) Thanks @tsu-builds.
|
||||
- Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.
|
||||
- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)
|
||||
- Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.
|
||||
- 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) Thanks @steipete.
|
||||
- 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.
|
||||
- Browser/Writable output path hardening: reject existing hardlinked writable targets, and finalize browser download/trace outputs via sibling temp files plus atomic rename to block hardlink-alias overwrite paths under browser temp roots.
|
||||
- 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) Thanks @steipete.
|
||||
- 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)
|
||||
- Models/Google Antigravity IDs: normalize bare `gemini-3-pro`, `gemini-3.1-pro`, and `gemini-3-1-pro` model IDs to the default `-low` thinking tier so provider requests no longer fail with 404 when the tier suffix is omitted. (#24145) Thanks @byungsker.
|
||||
- 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/Google Gemini: treat `google` (Gemini API key auth profile) as a reasoning-tag provider to prevent `<think>` leakage, and add forward-compat model fallback for `google-gemini-cli` `gemini-3.1-pro*` / `gemini-3.1-flash*` IDs to avoid false unknown-model errors. (#26551, #26524) 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 `systemRunBinding` 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/Command authorization: enforce sender authorization for natural-language abort triggers (`stop`-like text) and `/models` listings, preventing unauthorized session aborts and model-auth metadata disclosure. This ships in the next npm release (`2026.2.27`). 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: 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/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.
|
||||
- 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.
|
||||
- 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/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.
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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/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/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)
|
||||
- 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.
|
||||
- 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.
|
||||
- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation.
|
||||
- Memory/SQLite: deduplicate concurrent memory-manager initialization and auto-reopen stale SQLite handles after atomic reindex swaps, preventing repeated `attempt to write a readonly database` sync failures until gateway restart.
|
||||
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
|
||||
- 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.
|
||||
- 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.
|
||||
- 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)
|
||||
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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)
|
||||
- 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.
|
||||
- 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/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/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/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.
|
||||
- Browser/Error visibility: preserve browser-control application error messages (HTTP 4xx/5xx) instead of rewriting them as generic reachability failures. Landed from contributor PR #26380 by @TarasShyn.
|
||||
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
|
||||
- Feishu/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)
|
||||
- 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.
|
||||
- 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/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.
|
||||
- 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.
|
||||
- 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)
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
@@ -98,25 +392,31 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
|
||||
- Slack/Threading: resolve `replyToMode` per incoming message using chat-type-aware account config (`replyToModeByChatType` and legacy `dm.replyToMode`) so DM/channel reply threading honors overrides instead of always using monitor startup defaults. (#24717) Thanks @dbachelder.
|
||||
- Slack/Threading: track bot participation in message threads (per account/channel/thread) so follow-up messages in those threads can be handled without requiring repeated @mentions, while preserving mention-gating behavior for unrelated threads. (#29165) Thanks @luijoc.
|
||||
- Slack/Threading: stop forcing tool-call reply mode to `all` based on `ThreadLabel` alone; now force thread reply mode only when an explicit thread target exists (`MessageThreadId`/`ReplyToId`), so DM `replyToModeByChatType.direct` overrides are honored outside real thread replies. (#26251) Thanks @dbachelder.
|
||||
- Slack/Threading: when `replyToMode="all"` auto-threads top-level Slack DMs, seed the thread session key from the message `ts` so the initial message and later replies share the same isolated `:thread:` session instead of falling back to base DM context. (#26849) Thanks @calder-sandy.
|
||||
- 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)
|
||||
- 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) Thanks @steipete.
|
||||
- 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)
|
||||
- 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) Thanks @steipete.
|
||||
- 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/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) Thanks @steipete.
|
||||
- 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)
|
||||
- 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) Thanks @steipete.
|
||||
- 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) Thanks @steipete.
|
||||
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool ... not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.
|
||||
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
|
||||
- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
|
||||
- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
|
||||
@@ -151,6 +451,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
@@ -171,7 +472,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- 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 (`channel`/`to`/`thread`) 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.
|
||||
- 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.
|
||||
- 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)
|
||||
- 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) Thanks @steipete.
|
||||
- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (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)
|
||||
- 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.
|
||||
- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` 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.
|
||||
@@ -204,7 +505,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.
|
||||
- iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` 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.
|
||||
- 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.
|
||||
- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900)
|
||||
- Providers/Google reasoning: sanitize invalid negative `thinkingBudget` payloads for Gemini 3.1 requests by dropping `-1` budgets and mapping configured reasoning effort to `thinkingLevel`, preventing malformed reasoning payloads on `google-generative-ai`. (#25900) Thanks @steipete.
|
||||
- Providers/SiliconFlow: normalize `thinking="off"` to `thinking: null` for `Pro/*` model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.
|
||||
- Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.
|
||||
- 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.
|
||||
@@ -258,6 +559,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
@@ -287,7 +589,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305.
|
||||
- Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman.
|
||||
- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670)
|
||||
- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) Thanks @lailoo.
|
||||
- Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo.
|
||||
- Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk.
|
||||
- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86.
|
||||
@@ -313,11 +615,13 @@ 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.
|
||||
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
|
||||
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263)
|
||||
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) Thanks @steipete.
|
||||
- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc.
|
||||
- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. Thanks @nedlir for reporting.
|
||||
- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x.
|
||||
@@ -338,7 +642,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
|
||||
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
|
||||
- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
|
||||
- Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012)
|
||||
- Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012) Thanks @steipete.
|
||||
- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
|
||||
- Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc.
|
||||
- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
|
||||
@@ -368,14 +672,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC.
|
||||
- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao.
|
||||
- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12.
|
||||
- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693)
|
||||
- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) Thanks @steipete.
|
||||
- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
|
||||
- Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep.
|
||||
- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303)
|
||||
- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) Thanks @steipete.
|
||||
- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
|
||||
- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
|
||||
- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
|
||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations.
|
||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open`, and replace `files.uploadV2` with Slack’s external 3-step upload flow (`files.getUploadURLExternal` → presigned upload POST → `files.completeUploadExternal`) to avoid `missing_scope`/`invalid_arguments` upload failures in DM and threaded media replies.
|
||||
- Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux.
|
||||
- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle.
|
||||
- Webchat/Performance: reload `chat.history` after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz.
|
||||
@@ -391,7 +695,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/Webhook: add `channels.telegram.webhookPort` config support and pass it through plugin startup wiring to the monitor listener.
|
||||
- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807)
|
||||
- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
|
||||
- Browser/Remote CDP: extend stale-target recovery so `ensureTabAvailable()` now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict `tab not found` errors when multiple tabs exist; includes remote-profile regression tests. (#15989)
|
||||
- Browser/Remote CDP: extend stale-target recovery so `ensureTabAvailable()` now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict `tab not found` errors when multiple tabs exist; includes remote-profile regression tests. (#15989) Thanks @steipete.
|
||||
- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
|
||||
- Gateway/Pairing: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS.
|
||||
@@ -411,7 +715,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
|
||||
- Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory.
|
||||
- Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory.
|
||||
- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074)
|
||||
- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) Thanks @Takhoffman.
|
||||
- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor.
|
||||
- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:<id>` and `<chatId>:topic:<id>` routes are recognized again. (#24166) Thanks @obviyus.
|
||||
- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic.
|
||||
@@ -429,25 +733,25 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. Thanks @jiseoung for reporting.
|
||||
- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo.
|
||||
- Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227)
|
||||
- Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832)
|
||||
- Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) Thanks @steipete.
|
||||
- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337.
|
||||
- Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047)
|
||||
- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979)
|
||||
- Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) Thanks @steipete.
|
||||
- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) Thanks @steipete.
|
||||
- Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718)
|
||||
- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560)
|
||||
- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144)
|
||||
- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) Thanks @steipete.
|
||||
- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) Thanks @steipete.
|
||||
- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting.
|
||||
- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547)
|
||||
- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) Thanks @steipete.
|
||||
- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
|
||||
- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
|
||||
- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
|
||||
- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
|
||||
- Control UI/WebSocket: send a stable per-tab `instanceId` in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui.
|
||||
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
|
||||
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756)
|
||||
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) Thanks @steipete.
|
||||
- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
|
||||
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) Thanks @steipete.
|
||||
- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
|
||||
- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
|
||||
- Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed.
|
||||
@@ -927,6 +1231,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore.
|
||||
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
||||
- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas.
|
||||
- Cron/Isolation: treat non-finite `nextRunAtMs` as missing and repair isolated `every` anchor fallback so legacy jobs without valid timestamps self-heal and scheduler wake timing remains valid. (#19469) Thanks @guirguispierre.
|
||||
- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204.
|
||||
- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204.
|
||||
- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +56,11 @@ Welcome to the lobster tank! 🦞
|
||||
- **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)
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,5 +1,18 @@
|
||||
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
|
||||
|
||||
# OCI base-image metadata for downstream image consumers.
|
||||
# If you change these annotations, also update:
|
||||
# - docs/install/docker.md ("Base image metadata" section)
|
||||
# - https://docs.openclaw.ai/install/docker
|
||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \
|
||||
org.opencontainers.image.base.digest="sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935" \
|
||||
org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
|
||||
org.opencontainers.image.url="https://openclaw.ai" \
|
||||
org.opencontainers.image.documentation="https://docs.openclaw.ai/install/docker" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.title="OpenClaw" \
|
||||
org.opencontainers.image.description="OpenClaw gateway and CLI runtime container image"
|
||||
|
||||
# Install Bun (required for build scripts)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
@@ -46,11 +59,24 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
|
||||
USER node
|
||||
COPY --chown=node:node . .
|
||||
# Normalize copied plugin/agent paths so plugin safety checks do not reject
|
||||
# world-writable directories inherited from source file modes.
|
||||
RUN for dir in /app/extensions /app/.agent /app/.agents; do \
|
||||
if [ -d "$dir" ]; then \
|
||||
find "$dir" -type d -exec chmod 755 {} +; \
|
||||
find "$dir" -type f -exec chmod 644 {} +; \
|
||||
fi; \
|
||||
done
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
USER root
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Security hardening: Run as non-root user
|
||||
@@ -61,6 +87,11 @@ USER node
|
||||
# Start gateway server with default config.
|
||||
# Binds to loopback (127.0.0.1) by default for security.
|
||||
#
|
||||
# IMPORTANT: With Docker bridge networking (-p 18789:18789), loopback bind
|
||||
# makes the gateway unreachable from the host. Either:
|
||||
# - Use --network host, OR
|
||||
# - Override --bind to "lan" (0.0.0.0) and set auth credentials
|
||||
#
|
||||
# For container platforms requiring external health checks:
|
||||
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
|
||||
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
|
||||
|
||||
@@ -32,9 +32,9 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
## Sponsors
|
||||
|
||||
| OpenAI | Blacksmith |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://blacksmith.sh/) |
|
||||
| OpenAI | Blacksmith | Convex |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| [](https://openai.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
|
||||
|
||||
16
SECURITY.md
16
SECURITY.md
@@ -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`.
|
||||
|
||||
@@ -53,8 +54,9 @@ These are frequently reported but are typically closed with no code change:
|
||||
- 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) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- 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.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- 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.
|
||||
@@ -111,11 +113,12 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads)
|
||||
- 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 exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- 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 may be accepted as hardening improvements, but not as vulnerabilities.
|
||||
- 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.
|
||||
@@ -154,6 +157,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
|
||||
@@ -202,6 +206,14 @@ For threat model + hardening guidance (including `openclaw security audit --deep
|
||||
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths and native prompt image auto-load paths to the workspace directory.
|
||||
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
|
||||
|
||||
### Sub-agent delegation hardening
|
||||
|
||||
- Keep `sessions_spawn` denied unless you explicitly need delegated runs.
|
||||
- Keep `agents.list[].subagents.allowAgents` narrow, and only include agents with sandbox settings you trust.
|
||||
- When delegation must stay sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `inherit`).
|
||||
- `sandbox: "require"` rejects the spawn unless the target child runtime is sandboxed.
|
||||
- This prevents a less-restricted session from delegating work into an unsandboxed child by mistake.
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
|
||||
|
||||
160
appcast.xml
160
appcast.xml
@@ -209,84 +209,106 @@
|
||||
<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.25</title>
|
||||
<pubDate>Thu, 26 Feb 2026 05:14:17 +0100</pubDate>
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>14883</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.25</sparkle:shortVersionString>
|
||||
<sparkle:version>202602260</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.25</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<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 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>
|
||||
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
|
||||
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
|
||||
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> 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 <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
|
||||
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
|
||||
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
|
||||
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
|
||||
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<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>
|
||||
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
|
||||
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> 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.</li>
|
||||
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
|
||||
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
|
||||
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> 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.</li>
|
||||
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
|
||||
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
|
||||
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
|
||||
<li>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)</li>
|
||||
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> 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.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
|
||||
<li>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)</li>
|
||||
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
|
||||
<li>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)</li>
|
||||
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> 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.</li>
|
||||
<li>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 <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
|
||||
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> 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)</li>
|
||||
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) 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.</li>
|
||||
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
|
||||
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
|
||||
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
|
||||
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
|
||||
<li>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 <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
|
||||
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> 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)</li>
|
||||
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
|
||||
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
|
||||
<li>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.</li>
|
||||
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
|
||||
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> 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.</li>
|
||||
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> 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.</li>
|
||||
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
|
||||
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
|
||||
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> 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.</li>
|
||||
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
|
||||
<li>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.</li>
|
||||
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
|
||||
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
|
||||
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
|
||||
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
|
||||
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
|
||||
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
|
||||
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
|
||||
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> 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)</li>
|
||||
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
|
||||
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
|
||||
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
|
||||
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
|
||||
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
|
||||
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
|
||||
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> 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 (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
|
||||
<li>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 <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Config includes: harden <code>$include</code> 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 (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, 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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> 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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
|
||||
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> 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)</li>
|
||||
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
|
||||
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
|
||||
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> 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.</li>
|
||||
<li>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)</li>
|
||||
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
|
||||
<li>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 <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
|
||||
<li>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.</li>
|
||||
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</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.25/OpenClaw-2026.2.25.zip" length="23078398" type="application/octet-stream" sparkle:edSignature="PJjvRhivhybV5bYr8u1C9Dyw4h8yePGwG8SFsr4QRqMSBYMEedraPJO3KNbkoChjclYUYf3oGcC4daNZnFvgBA=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -9,14 +9,14 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
|
||||
- [x] Encrypted persistence for gateway setup/auth state
|
||||
- [x] Chat UI restyled
|
||||
- [x] Settings UI restyled and de-duplicated (gateway controls moved to Connect)
|
||||
- [ ] QR code scanning in onboarding
|
||||
- [ ] Performance improvements
|
||||
- [ ] Streaming support in chat UI
|
||||
- [ ] Request camera/location and other permissions in onboarding/settings flow
|
||||
- [ ] Push notifications for gateway/chat status updates
|
||||
- [ ] Security hardening (biometric lock, token handling, safer defaults)
|
||||
- [ ] Voice tab full functionality
|
||||
- [ ] Screen tab full functionality
|
||||
- [x] QR code scanning in onboarding
|
||||
- [x] Performance improvements
|
||||
- [x] Streaming support in chat UI
|
||||
- [x] Request camera/location and other permissions in onboarding/settings flow
|
||||
- [x] Push notifications for gateway/chat status updates
|
||||
- [x] Security hardening (biometric lock, token handling, safer defaults)
|
||||
- [x] Voice tab full functionality
|
||||
- [x] Screen tab full functionality
|
||||
- [ ] Full end-to-end QA and release hardening
|
||||
|
||||
## Open in Android Studio
|
||||
@@ -32,6 +32,28 @@ cd apps/android
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
## Kotlin Lint + Format
|
||||
|
||||
```bash
|
||||
pnpm android:lint
|
||||
pnpm android:format
|
||||
```
|
||||
|
||||
Android framework/resource lint (separate pass):
|
||||
|
||||
```bash
|
||||
pnpm android:lint:android
|
||||
```
|
||||
|
||||
Direct Gradle tasks:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :app:ktlintCheck :benchmark:ktlintCheck
|
||||
./gradlew :app:ktlintFormat :benchmark:ktlintFormat
|
||||
./gradlew :app:lintDebug
|
||||
```
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Macrobenchmark (Startup + Frame Timing)
|
||||
@@ -150,6 +172,56 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
Pre-req checklist:
|
||||
|
||||
1) Gateway is running and reachable from the Android app.
|
||||
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
|
||||
3) App stays unlocked and in foreground for the whole run.
|
||||
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
|
||||
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
|
||||
6) No interactive system dialogs should be pending before test start.
|
||||
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
|
||||
8) Local operator test client pairing is approved. If first run fails with `pairing required`, approve latest pending device pairing request, then rerun:
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve --latest
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm android:test:integration
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
|
||||
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
|
||||
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
|
||||
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
|
||||
|
||||
What it does:
|
||||
|
||||
- Reads `node.describe` command list from the selected Android node.
|
||||
- Invokes advertised non-interactive commands.
|
||||
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
|
||||
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
|
||||
|
||||
Common failure quick-fixes:
|
||||
|
||||
- `pairing required` before tests start:
|
||||
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
|
||||
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
|
||||
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
|
||||
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
|
||||
|
||||
## Contributions
|
||||
|
||||
This Android app is currently being rebuilt.
|
||||
|
||||
@@ -1,156 +1,167 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.android.application")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.android"
|
||||
compileSdk = 36
|
||||
namespace = "ai.openclaw.android"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
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")
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603010
|
||||
versionName = "2026.3.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
)
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"GradleDependency",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes +=
|
||||
setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
lint {
|
||||
disable +=
|
||||
setOf(
|
||||
"GradleDependency",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
|
||||
val outputFileName = "openclaw-${versionName}-${buildType}.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
val outputFileName = "openclaw-$versionName-$buildType.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
exclude("**/build/**")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.02.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
// 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.7")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
// 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.7")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
// Material Components (XML theme + resources)
|
||||
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.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
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")
|
||||
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")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
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")
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
implementation("androidx.camera:camera-camera2:1.5.2")
|
||||
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")
|
||||
// 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.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")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
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")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
|
||||
@@ -47,6 +47,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
|
||||
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
|
||||
val micStatusText: StateFlow<String> = runtime.micStatusText
|
||||
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
|
||||
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
|
||||
@@ -54,6 +55,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
||||
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
@@ -129,10 +131,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
runtime.setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
runtime.setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
runtime.setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
runtime.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.voice.MicCaptureManager
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -65,8 +66,6 @@ class NodeRuntime(context: Context) {
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
prefs = prefs,
|
||||
connectedEndpoint = { connectedEndpoint },
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
triggerCameraFlash = ::triggerCameraFlash,
|
||||
@@ -100,6 +99,26 @@ class NodeRuntime(context: Context) {
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val systemHandler: SystemHandler = SystemHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val photosHandler: PhotosHandler = PhotosHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val contactsHandler: ContactsHandler = ContactsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val calendarHandler: CalendarHandler = CalendarHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val motionHandler: MotionHandler = MotionHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -122,6 +141,8 @@ class NodeRuntime(context: Context) {
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
@@ -133,6 +154,11 @@ class NodeRuntime(context: Context) {
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
systemHandler = systemHandler,
|
||||
photosHandler = photosHandler,
|
||||
contactsHandler = contactsHandler,
|
||||
calendarHandler = calendarHandler,
|
||||
motionHandler = motionHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -143,12 +169,15 @@ class NodeRuntime(context: Context) {
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
@@ -220,7 +249,12 @@ class NodeRuntime(context: Context) {
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
@@ -275,6 +309,14 @@ class NodeRuntime(context: Context) {
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
DeviceNotificationListenerService.setNodeEventSink { event, payloadJson ->
|
||||
scope.launch {
|
||||
nodeSession.sendNodeEvent(event = event, payloadJson = payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val chat: ChatController =
|
||||
ChatController(
|
||||
scope = scope,
|
||||
@@ -282,12 +324,31 @@ class NodeRuntime(context: Context) {
|
||||
json = json,
|
||||
supportsChatSubscribe = false,
|
||||
)
|
||||
private val voiceReplySpeakerLazy: Lazy<TalkModeManager> = lazy {
|
||||
// Reuse the existing TalkMode speech engine (ElevenLabs + deterministic system-TTS fallback)
|
||||
// without enabling the legacy talk capture loop.
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
).also { speaker ->
|
||||
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
|
||||
}
|
||||
}
|
||||
private val voiceReplySpeaker: TalkModeManager
|
||||
get() = voiceReplySpeakerLazy.value
|
||||
|
||||
private val micCapture: MicCaptureManager by lazy {
|
||||
MicCaptureManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
sendToGateway = { message ->
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
val idempotencyKey = UUID.randomUUID().toString()
|
||||
// Notify MicCaptureManager of the idempotency key *before* the network
|
||||
// call so pendingRunId is set before any chat events can arrive.
|
||||
onRunIdKnown(idempotencyKey)
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
||||
@@ -299,6 +360,13 @@ class NodeRuntime(context: Context) {
|
||||
val response = operatorSession.request("chat.send", params.toString())
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Skip if TalkModeManager is handling TTS (ttsOnAllResponses) to avoid
|
||||
// double-speaking the same assistant reply from both pipelines.
|
||||
if (!talkMode.ttsOnAllResponses) {
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -314,6 +382,9 @@ class NodeRuntime(context: Context) {
|
||||
val micEnabled: StateFlow<Boolean>
|
||||
get() = micCapture.micEnabled
|
||||
|
||||
val micCooldown: StateFlow<Boolean>
|
||||
get() = micCapture.micCooldown
|
||||
|
||||
val micQueuedMessages: StateFlow<List<String>>
|
||||
get() = micCapture.queuedMessages
|
||||
|
||||
@@ -326,11 +397,22 @@ class NodeRuntime(context: Context) {
|
||||
val micIsSending: StateFlow<Boolean>
|
||||
get() = micCapture.isSending
|
||||
|
||||
private val talkMode: TalkModeManager by lazy {
|
||||
TalkModeManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = true,
|
||||
isConnected = { operatorConnected },
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -468,7 +550,14 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
// MicCaptureManager handles STT + send to gateway.
|
||||
// TalkModeManager plays TTS on assistant responses.
|
||||
micCapture.setMicEnabled(enabled)
|
||||
if (enabled) {
|
||||
// Mic on = user is on voice screen and wants TTS responses.
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
@@ -576,12 +665,41 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
if (!active) {
|
||||
// User left voice screen — stop mic and TTS
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.stopTts()
|
||||
micCapture.setMicEnabled(false)
|
||||
prefs.setTalkEnabled(false)
|
||||
}
|
||||
// Don't re-enable on active=true; mic toggle drives that
|
||||
}
|
||||
|
||||
fun setMicEnabled(value: Boolean) {
|
||||
prefs.setTalkEnabled(value)
|
||||
if (value) {
|
||||
// Tapping mic on interrupts any active TTS (barge-in)
|
||||
talkMode.stopTts()
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
micCapture.setMicEnabled(value)
|
||||
externalAudioCaptureActive.value = value
|
||||
}
|
||||
|
||||
val speakerEnabled: StateFlow<Boolean>
|
||||
get() = prefs.speakerEnabled
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
prefs.setSpeakerEnabled(value)
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.setPlaybackEnabled(value)
|
||||
}
|
||||
// Keep TalkMode in sync so speaker mute works when ttsOnAllResponses is active.
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
@@ -763,6 +881,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
micCapture.handleGatewayEvent(event, payloadJson)
|
||||
talkMode.handleGatewayEvent(event, payloadJson)
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,9 @@ class SecurePrefs(context: Context) {
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
@@ -270,6 +273,11 @@ class SecurePrefs(context: Context) {
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
@@ -311,17 +311,14 @@ class ChatController(
|
||||
if (!sessionKey.isNullOrEmpty() && sessionKey != _sessionKey.value) return
|
||||
|
||||
val runId = payload["runId"].asStringOrNull()
|
||||
if (runId != null) {
|
||||
val isPending =
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.contains(runId)
|
||||
}
|
||||
if (!isPending) return
|
||||
}
|
||||
val isPending =
|
||||
if (runId != null) synchronized(pendingRuns) { pendingRuns.contains(runId) } else true
|
||||
|
||||
val state = payload["state"].asStringOrNull()
|
||||
when (state) {
|
||||
"delta" -> {
|
||||
// Only show streaming text for runs we initiated
|
||||
if (!isPending) return
|
||||
val text = parseAssistantDeltaText(payload)
|
||||
if (!text.isNullOrEmpty()) {
|
||||
_streamingAssistantText.value = text
|
||||
|
||||
@@ -173,6 +173,47 @@ class GatewaySession(
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
|
||||
val conn = currentConnection ?: return false
|
||||
val response =
|
||||
try {
|
||||
conn.request(
|
||||
"node.canvas.capability.refresh",
|
||||
params = buildJsonObject {},
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
if (!response.ok) {
|
||||
val err = response.error
|
||||
Log.w(
|
||||
"OpenClawGateway",
|
||||
"node.canvas.capability.refresh rejected: ${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
|
||||
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
|
||||
if (refreshedCapability.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
|
||||
return false
|
||||
}
|
||||
val scopedCanvasHostUrl = canvasHostUrl?.trim().orEmpty()
|
||||
if (scopedCanvasHostUrl.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing local canvasHostUrl")
|
||||
return false
|
||||
}
|
||||
val refreshedUrl = replaceCanvasCapabilityInScopedHostUrl(scopedCanvasHostUrl, refreshedCapability)
|
||||
if (refreshedUrl == null) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh unable to rewrite scoped canvas URL")
|
||||
return false
|
||||
}
|
||||
canvasHostUrl = refreshedUrl
|
||||
return true
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
@@ -501,11 +542,16 @@ class GatewaySession(
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result)
|
||||
sendInvokeResult(id, nodeId, result, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||
private suspend fun sendInvokeResult(
|
||||
id: String,
|
||||
nodeId: String,
|
||||
result: InvokeResult,
|
||||
invokeTimeoutMs: Long?,
|
||||
) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
@@ -527,10 +573,14 @@ class GatewaySession(
|
||||
)
|
||||
}
|
||||
}
|
||||
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||
Log.w(
|
||||
loggerTag,
|
||||
"node.invoke.result failed (ackTimeoutMs=$ackTimeoutMs): ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,3 +737,24 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceCanvasCapabilityInScopedHostUrl(
|
||||
scopedUrl: String,
|
||||
capability: String,
|
||||
): String? {
|
||||
val marker = "/__openclaw__/cap/"
|
||||
val markerStart = scopedUrl.indexOf(marker)
|
||||
if (markerStart < 0) return null
|
||||
val capabilityStart = markerStart + marker.length
|
||||
val slashEnd = scopedUrl.indexOf("/", capabilityStart).takeIf { it >= 0 }
|
||||
val queryEnd = scopedUrl.indexOf("?", capabilityStart).takeIf { it >= 0 }
|
||||
val fragmentEnd = scopedUrl.indexOf("#", capabilityStart).takeIf { it >= 0 }
|
||||
val capabilityEnd = listOfNotNull(slashEnd, queryEnd, fragmentEnd).minOrNull() ?: scopedUrl.length
|
||||
if (capabilityEnd <= capabilityStart) return null
|
||||
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)
|
||||
}
|
||||
|
||||
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
|
||||
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
|
||||
return normalized.coerceIn(15_000L, 120_000L)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.TimeZone
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CALENDAR_LIMIT = 50
|
||||
|
||||
internal data class CalendarEventsRequest(
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class CalendarAddRequest(
|
||||
val title: String,
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val notes: String?,
|
||||
val calendarId: Long?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal data class CalendarEventRecord(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val isAllDay: Boolean,
|
||||
val location: String?,
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
internal interface CalendarDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord>
|
||||
|
||||
fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord
|
||||
}
|
||||
|
||||
private object SystemCalendarDataSource : CalendarDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
ContentUris.appendId(builder, request.startMs)
|
||||
ContentUris.appendId(builder, request.endMs)
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
|
||||
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = mutableListOf<CalendarEventRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val id = cursor.getLong(0)
|
||||
val title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" }
|
||||
val beginMs = cursor.getLong(2)
|
||||
val endMs = cursor.getLong(3)
|
||||
val isAllDay = cursor.getInt(4) == 1
|
||||
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
|
||||
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
|
||||
out +=
|
||||
CalendarEventRecord(
|
||||
identifier = id.toString(),
|
||||
title = title,
|
||||
startISO = Instant.ofEpochMilli(beginMs).toString(),
|
||||
endISO = Instant.ofEpochMilli(endMs).toString(),
|
||||
isAllDay = isAllDay,
|
||||
location = location,
|
||||
calendarTitle = calendarTitle,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
|
||||
val resolver = context.contentResolver
|
||||
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
|
||||
put(CalendarContract.Events.TITLE, request.title)
|
||||
put(CalendarContract.Events.DTSTART, request.startMs)
|
||||
put(CalendarContract.Events.DTEND, request.endMs)
|
||||
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
|
||||
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
val eventId = uri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
return loadEventById(resolver, eventId)
|
||||
?: throw IllegalStateException("calendar insert failed")
|
||||
}
|
||||
|
||||
private fun resolveCalendarId(
|
||||
resolver: ContentResolver,
|
||||
calendarId: Long?,
|
||||
calendarTitle: String?,
|
||||
): Long {
|
||||
if (calendarId != null) {
|
||||
if (calendarExists(resolver, calendarId)) return calendarId
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
|
||||
}
|
||||
if (!calendarTitle.isNullOrEmpty()) {
|
||||
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
|
||||
}
|
||||
findDefaultCalendarId(resolver)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
|
||||
}
|
||||
|
||||
private fun calendarExists(resolver: ContentResolver, id: Long): Boolean {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars._ID}=?",
|
||||
arrayOf(id.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
return cursor != null && cursor.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCalendarByTitle(resolver: ContentResolver, title: String): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
|
||||
arrayOf(title),
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
|
||||
val projection = arrayOf(CalendarContract.Calendars._ID)
|
||||
resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Calendars.VISIBLE}=1",
|
||||
null,
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEventById(
|
||||
resolver: ContentResolver,
|
||||
eventId: Long,
|
||||
): CalendarEventRecord? {
|
||||
val projection =
|
||||
arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
projection,
|
||||
"${CalendarContract.Events._ID}=?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return CalendarEventRecord(
|
||||
identifier = cursor.getLong(0).toString(),
|
||||
title = cursor.getString(1)?.trim().orEmpty().ifEmpty { "(untitled)" },
|
||||
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
|
||||
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
|
||||
isAllDay = cursor.getInt(4) == 1,
|
||||
location = cursor.getString(5)?.trim()?.ifEmpty { null },
|
||||
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
|
||||
|
||||
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseEventsRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val events = dataSource.events(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"events",
|
||||
buildJsonArray { events.forEach { add(eventJson(it)) } },
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_PERMISSION_REQUIRED",
|
||||
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
if (request.title.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: title required",
|
||||
)
|
||||
}
|
||||
if (request.endMs <= request.startMs) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_INVALID",
|
||||
message = "CALENDAR_INVALID: endISO must be after startISO",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val event = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("event", eventJson(event))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
|
||||
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
|
||||
GatewaySession.InvokeResult.error(code = code, message = msg)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALENDAR_UNAVAILABLE",
|
||||
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
val start = Instant.now()
|
||||
val end = start.plus(7, ChronoUnit.DAYS)
|
||||
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
val resolvedStart = start ?: Instant.now()
|
||||
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
|
||||
return CalendarEventsRequest(
|
||||
startMs = resolvedStart.toEpochMilli(),
|
||||
endMs = resolvedEnd.toEpochMilli(),
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
?: return null
|
||||
return CalendarAddRequest(
|
||||
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
|
||||
startMs = start.toEpochMilli(),
|
||||
endMs = end.toEpochMilli(),
|
||||
isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false,
|
||||
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
|
||||
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseISO(raw: String?): Instant? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
return try {
|
||||
Instant.parse(value)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventJson(event: CalendarEventRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(event.identifier))
|
||||
put("title", JsonPrimitive(event.title))
|
||||
put("startISO", JsonPrimitive(event.startISO))
|
||||
put("endISO", JsonPrimitive(event.endISO))
|
||||
put("isAllDay", JsonPrimitive(event.isAllDay))
|
||||
event.location?.let { put("location", JsonPrimitive(it)) }
|
||||
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: CalendarDataSource,
|
||||
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.util.Base64
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
@@ -30,6 +33,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -40,6 +47,12 @@ import kotlin.coroutines.resumeWithException
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
|
||||
data class CameraDeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: String,
|
||||
val deviceType: String,
|
||||
)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
@@ -52,6 +65,14 @@ class CameraCaptureManager(private val context: Context) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun listDevices(): List<CameraDeviceInfo> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val provider = context.cameraProvider()
|
||||
provider.availableCameraInfos
|
||||
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
|
||||
.sortedBy { it.id }
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
@@ -80,14 +101,15 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
val deviceId = parseDeviceId(params)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
@@ -145,12 +167,14 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val deviceId = parseDeviceId(params)
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio")
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
|
||||
@@ -162,8 +186,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
)
|
||||
.build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
// CameraX requires a Preview use case for the camera to start producing frames;
|
||||
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
|
||||
@@ -270,49 +293,104 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
paramsJson?.contains("\"back\"") == true -> "back"
|
||||
else -> null
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQuality(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
private fun resolveCameraSelector(
|
||||
provider: ProcessCameraProvider,
|
||||
facing: String,
|
||||
deviceId: String?,
|
||||
): CameraSelector {
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
}
|
||||
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
|
||||
if (!availableIds.contains(deviceId)) {
|
||||
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
|
||||
}
|
||||
return CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
|
||||
val cameraId = cameraIdOrNull(info) ?: return null
|
||||
val lensFacing =
|
||||
runCatching {
|
||||
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
|
||||
}.getOrNull()
|
||||
val position =
|
||||
when (lensFacing) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unspecified"
|
||||
}
|
||||
val deviceType =
|
||||
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
|
||||
val name =
|
||||
when (position) {
|
||||
"front" -> "Front Camera"
|
||||
"back" -> "Back Camera"
|
||||
"external" -> "External Camera"
|
||||
else -> "Camera $cameraId"
|
||||
}
|
||||
return CameraDeviceInfo(
|
||||
id = cameraId,
|
||||
name = name,
|
||||
position = position,
|
||||
deviceType = deviceType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cameraIdOrNull(info: CameraInfo): String? =
|
||||
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
|
||||
@@ -3,25 +3,57 @@ package ai.openclaw.android.node
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.CameraHudKind
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
|
||||
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val prefs: SecurePrefs,
|
||||
private val connectedEndpoint: () -> GatewayEndpoint?,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
private val triggerCameraFlash: () -> Unit,
|
||||
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
val devices = camera.listDevices()
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"devices",
|
||||
buildJsonArray {
|
||||
devices.forEach { device ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(device.id))
|
||||
put("name", JsonPrimitive(device.name))
|
||||
put("position", JsonPrimitive(device.position))
|
||||
put("deviceType", JsonPrimitive(device.deviceType))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
@@ -69,7 +101,7 @@ class CameraHandler(
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
@@ -89,62 +121,28 @@ class CameraHandler(
|
||||
showCameraHud(message, CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
// Upload file via HTTP instead of base64 through WebSocket
|
||||
clipLog("uploading via HTTP...")
|
||||
val uploadUrl = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ep = connectedEndpoint()
|
||||
val gatewayHost = if (ep != null) {
|
||||
val isHttps = ep.tlsEnabled || ep.port == 443
|
||||
if (!isHttps) {
|
||||
clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64")
|
||||
throw Exception("HTTPS required for upload (bearer token protection)")
|
||||
}
|
||||
if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}"
|
||||
} else {
|
||||
clipLog("error: no gateway endpoint connected, cannot upload")
|
||||
throw Exception("no gateway endpoint connected")
|
||||
}
|
||||
val token = prefs.loadGatewayToken() ?: ""
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val body = filePayload.file.asRequestBody("video/mp4".toMediaType())
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url("$gatewayHost/upload/clip.mp4")
|
||||
.put(body)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4")
|
||||
val resp = client.newCall(req).execute()
|
||||
val respBody = resp.body?.string() ?: ""
|
||||
clipLog("upload response: ${resp.code} $respBody")
|
||||
filePayload.file.delete()
|
||||
if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}")
|
||||
// Parse URL from response
|
||||
val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody)
|
||||
urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clipLog("upload failed: ${err.message}, falling back to base64")
|
||||
// Fallback to base64 if upload fails
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
val rawBytes = filePayload.file.length()
|
||||
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
|
||||
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
|
||||
withContext(Dispatchers.IO) { filePayload.file.delete() }
|
||||
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PAYLOAD_TOO_LARGE",
|
||||
message =
|
||||
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
|
||||
)
|
||||
}
|
||||
clipLog("returning URL result: $uploadUrl")
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
clipLog("returning base64 payload")
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
@@ -154,4 +152,24 @@ class CameraHandler(
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
val root =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val value =
|
||||
(root["includeAudio"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
|
||||
@@ -16,6 +15,8 @@ class ConnectionManager(
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
@@ -73,28 +74,20 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
private fun runtimeFlags(): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.Device.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(OpenClawCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
|
||||
|
||||
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
|
||||
|
||||
fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CONTACTS_LIMIT = 25
|
||||
|
||||
internal data class ContactRecord(
|
||||
val identifier: String,
|
||||
val displayName: String,
|
||||
val givenName: String,
|
||||
val familyName: String,
|
||||
val organizationName: String,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal data class ContactsSearchRequest(
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class ContactsAddRequest(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val organizationName: String?,
|
||||
val displayName: String?,
|
||||
val phoneNumbers: List<String>,
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
internal interface ContactsDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun hasWritePermission(context: Context): Boolean
|
||||
|
||||
fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord>
|
||||
|
||||
fun add(context: Context, request: ContactsAddRequest): ContactRecord
|
||||
}
|
||||
|
||||
private object SystemContactsDataSource : ContactsDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.Contacts._ID,
|
||||
ContactsContract.Contacts.DISPLAY_NAME_PRIMARY,
|
||||
)
|
||||
val selection: String?
|
||||
val selectionArgs: Array<String>?
|
||||
if (request.query.isNullOrBlank()) {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
|
||||
selectionArgs = arrayOf("%${request.query}%")
|
||||
}
|
||||
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
|
||||
resolver.query(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val idIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)
|
||||
val displayNameIndex = cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME_PRIMARY)
|
||||
val out = mutableListOf<ContactRecord>()
|
||||
while (cursor.moveToNext() && out.size < request.limit) {
|
||||
val contactId = cursor.getLong(idIndex)
|
||||
val displayName = cursor.getString(displayNameIndex).orEmpty()
|
||||
out += loadContactRecord(resolver, contactId, fallbackDisplayName = displayName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
|
||||
val resolver = context.contentResolver
|
||||
val operations = ArrayList<ContentProviderOperation>()
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
|
||||
.build()
|
||||
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, request.givenName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, request.familyName)
|
||||
.withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, request.displayName)
|
||||
.build()
|
||||
}
|
||||
if (!request.organizationName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, request.organizationName)
|
||||
.build()
|
||||
}
|
||||
request.phoneNumbers.forEach { number ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, number)
|
||||
.withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
|
||||
.build()
|
||||
}
|
||||
request.emails.forEach { email ->
|
||||
operations +=
|
||||
ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
|
||||
.withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
|
||||
.withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
|
||||
.withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_HOME)
|
||||
.build()
|
||||
}
|
||||
|
||||
val results = resolver.applyBatch(ContactsContract.AUTHORITY, operations)
|
||||
val rawContactUri = results.firstOrNull()?.uri
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val rawContactId = rawContactUri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val contactId = resolveContactIdForRawContact(resolver, rawContactId)
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
return loadContactRecord(
|
||||
resolver = resolver,
|
||||
contactId = contactId,
|
||||
fallbackDisplayName = request.displayName.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveContactIdForRawContact(resolver: ContentResolver, rawContactId: Long): Long? {
|
||||
val projection = arrayOf(ContactsContract.RawContacts.CONTACT_ID)
|
||||
resolver.query(
|
||||
ContactsContract.RawContacts.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.RawContacts._ID}=?",
|
||||
arrayOf(rawContactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
val index = cursor.getColumnIndexOrThrow(ContactsContract.RawContacts.CONTACT_ID)
|
||||
return cursor.getLong(index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContactRecord(
|
||||
resolver: ContentResolver,
|
||||
contactId: Long,
|
||||
fallbackDisplayName: String,
|
||||
): ContactRecord {
|
||||
val nameRow = loadNameRow(resolver, contactId)
|
||||
val organization = loadOrganization(resolver, contactId)
|
||||
val phones = loadPhones(resolver, contactId)
|
||||
val emails = loadEmails(resolver, contactId)
|
||||
val displayName =
|
||||
when {
|
||||
!nameRow.displayName.isNullOrEmpty() -> nameRow.displayName
|
||||
!fallbackDisplayName.isNullOrEmpty() -> fallbackDisplayName
|
||||
else -> listOfNotNull(nameRow.givenName, nameRow.familyName).joinToString(" ").trim()
|
||||
}.ifEmpty { "(unnamed)" }
|
||||
return ContactRecord(
|
||||
identifier = contactId.toString(),
|
||||
displayName = displayName,
|
||||
givenName = nameRow.givenName.orEmpty(),
|
||||
familyName = nameRow.familyName.orEmpty(),
|
||||
organizationName = organization.orEmpty(),
|
||||
phoneNumbers = phones,
|
||||
emails = emails,
|
||||
)
|
||||
}
|
||||
|
||||
private data class NameRow(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
private fun loadNameRow(resolver: ContentResolver, contactId: Long): NameRow {
|
||||
val projection =
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME,
|
||||
ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(
|
||||
contactId.toString(),
|
||||
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
|
||||
),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return NameRow(givenName = null, familyName = null, displayName = null)
|
||||
}
|
||||
val given = cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
val family = cursor.getString(1)?.trim()?.ifEmpty { null }
|
||||
val display = cursor.getString(2)?.trim()?.ifEmpty { null }
|
||||
return NameRow(givenName = given, familyName = family, displayName = display)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOrganization(resolver: ContentResolver, contactId: Long): String? {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Organization.COMPANY)
|
||||
resolver.query(
|
||||
ContactsContract.Data.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.Data.CONTACT_ID}=? AND ${ContactsContract.Data.MIMETYPE}=?",
|
||||
arrayOf(contactId.toString(), ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
return cursor.getString(0)?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContactsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: ContactsDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
|
||||
|
||||
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseSearchRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val contacts = dataSource.search(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"contacts",
|
||||
buildJsonArray {
|
||||
contacts.forEach { add(contactJson(it)) }
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contacts query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_PERMISSION_REQUIRED",
|
||||
message = "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseAddRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val hasName =
|
||||
!(request.givenName.isNullOrEmpty() && request.familyName.isNullOrEmpty() && request.displayName.isNullOrEmpty())
|
||||
val hasOrg = !request.organizationName.isNullOrEmpty()
|
||||
val hasDetails = request.phoneNumbers.isNotEmpty() || request.emails.isNotEmpty()
|
||||
if (!hasName && !hasOrg && !hasDetails) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_INVALID",
|
||||
message = "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val contact = dataSource.add(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("contact", contactJson(contact))
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CONTACTS_UNAVAILABLE",
|
||||
message = "CONTACTS_UNAVAILABLE: ${err.message ?: "contact add failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSearchRequest(paramsJson: String?): ContactsSearchRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return ContactsSearchRequest(query = null, limit = DEFAULT_CONTACTS_LIMIT)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
|
||||
return ContactsSearchRequest(query = query, limit = limit)
|
||||
}
|
||||
|
||||
private fun parseAddRequest(paramsJson: String?): ContactsAddRequest? {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return ContactsAddRequest(
|
||||
givenName = (params["givenName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
familyName = (params["familyName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
|
||||
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringArray(array: JsonArray?): List<String> {
|
||||
if (array == null) return emptyList()
|
||||
return array.mapNotNull { element ->
|
||||
(element as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private fun contactJson(contact: ContactRecord): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("identifier", JsonPrimitive(contact.identifier))
|
||||
put("displayName", JsonPrimitive(contact.displayName))
|
||||
put("givenName", JsonPrimitive(contact.givenName))
|
||||
put("familyName", JsonPrimitive(contact.familyName))
|
||||
put("organizationName", JsonPrimitive(contact.organizationName))
|
||||
put("phoneNumbers", buildJsonArray { contact.phoneNumbers.forEach { add(JsonPrimitive(it)) } })
|
||||
put("emails", buildJsonArray { contact.emails.forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: ContactsDataSource,
|
||||
): ContactsHandler = ContactsHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ class DebugHandler(
|
||||
results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""")
|
||||
val diagnostics = results.joinToString("\n")
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
|
||||
} catch (e: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
@@ -11,6 +14,7 @@ import android.os.Environment
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
@@ -22,6 +26,13 @@ import kotlinx.serialization.json.put
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
) {
|
||||
private data class BatterySnapshot(
|
||||
val status: Int,
|
||||
val plugged: Int,
|
||||
val levelFraction: Double?,
|
||||
val temperatureC: Double?,
|
||||
)
|
||||
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
}
|
||||
@@ -30,12 +41,16 @@ class DeviceHandler(
|
||||
return GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val batteryStatus =
|
||||
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val batteryLevel = batteryLevelFraction(batteryIntent)
|
||||
val battery = readBatterySnapshot()
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val storage = StatFs(Environment.getDataDirectory().absolutePath)
|
||||
val totalBytes = storage.totalBytes
|
||||
@@ -50,8 +65,8 @@ class DeviceHandler(
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
batteryLevel?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
|
||||
battery.levelFraction?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
@@ -112,6 +127,204 @@ class DeviceHandler(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun permissionsPayloadJson(): String {
|
||||
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
val motionGranted =
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
val notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"camera",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.CAMERA),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"microphone",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.RECORD_AUDIO),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"location",
|
||||
permissionStateJson(
|
||||
granted =
|
||||
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"backgroundLocation",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"sms",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notificationListener",
|
||||
permissionStateJson(
|
||||
granted = notificationAccess,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notifications",
|
||||
permissionStateJson(
|
||||
granted = notificationsGranted,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"photos",
|
||||
permissionStateJson(
|
||||
granted = photosGranted,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"contacts",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CONTACTS),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"calendar",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.READ_CALENDAR),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"motion",
|
||||
permissionStateJson(
|
||||
granted = motionGranted,
|
||||
promptableWhenDenied = Build.VERSION.SDK_INT >= 29,
|
||||
),
|
||||
)
|
||||
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
|
||||
put(
|
||||
"screenCapture",
|
||||
permissionStateJson(
|
||||
granted = false,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun healthPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val batteryManager = appContext.getSystemService(BatteryManager::class.java)
|
||||
val currentNowUa = batteryManager?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||
val currentNowMa =
|
||||
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
|
||||
null
|
||||
} else {
|
||||
currentNowUa.toDouble() / 1_000.0
|
||||
}
|
||||
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val activityManager = appContext.getSystemService(ActivityManager::class.java)
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager?.getMemoryInfo(memoryInfo)
|
||||
val totalRamBytes = memoryInfo.totalMem.coerceAtLeast(0L)
|
||||
val availableRamBytes = memoryInfo.availMem.coerceAtLeast(0L)
|
||||
val usedRamBytes = (totalRamBytes - availableRamBytes).coerceAtLeast(0L)
|
||||
val lowMemory = memoryInfo.lowMemory
|
||||
val memoryPressure = mapMemoryPressure(totalRamBytes, availableRamBytes, lowMemory)
|
||||
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"memory",
|
||||
buildJsonObject {
|
||||
put("pressure", JsonPrimitive(memoryPressure))
|
||||
put("totalRamBytes", JsonPrimitive(totalRamBytes))
|
||||
put("availableRamBytes", JsonPrimitive(availableRamBytes))
|
||||
put("usedRamBytes", JsonPrimitive(usedRamBytes))
|
||||
put("thresholdBytes", JsonPrimitive(memoryInfo.threshold.coerceAtLeast(0L)))
|
||||
put("lowMemory", JsonPrimitive(lowMemory))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
put("chargingType", JsonPrimitive(mapChargingType(battery.plugged)))
|
||||
battery.temperatureC?.let { put("temperatureC", JsonPrimitive(it)) }
|
||||
currentNowMa?.let { put("currentMa", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
put(
|
||||
"power",
|
||||
buildJsonObject {
|
||||
put("dozeModeEnabled", JsonPrimitive(powerManager?.isDeviceIdleMode == true))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"system",
|
||||
buildJsonObject {
|
||||
Build.VERSION.SECURITY_PATCH
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { put("securityPatchLevel", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val status =
|
||||
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
|
||||
val temperatureC =
|
||||
intent
|
||||
?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
|
||||
?.takeIf { it != Int.MIN_VALUE }
|
||||
?.toDouble()
|
||||
?.div(10.0)
|
||||
return BatterySnapshot(
|
||||
status = status,
|
||||
plugged = plugged,
|
||||
levelFraction = batteryLevelFraction(intent),
|
||||
temperatureC = temperatureC,
|
||||
)
|
||||
}
|
||||
|
||||
private fun batteryLevelFraction(intent: Intent?): Double? {
|
||||
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
@@ -128,6 +341,16 @@ class DeviceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapChargingType(plugged: Int): String {
|
||||
return when (plugged) {
|
||||
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
|
||||
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
|
||||
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
|
||||
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
|
||||
else -> "none"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapThermalState(powerManager: PowerManager?): String {
|
||||
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
|
||||
return when (thermal) {
|
||||
@@ -150,6 +373,30 @@ class DeviceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
|
||||
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
|
||||
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
|
||||
if (lowMemory) return "critical"
|
||||
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
|
||||
return when {
|
||||
freeRatio <= 0.05 -> "critical"
|
||||
freeRatio <= 0.15 -> "high"
|
||||
freeRatio <= 0.30 -> "moderate"
|
||||
else -> "normal"
|
||||
}
|
||||
}
|
||||
|
||||
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
|
||||
buildJsonArray {
|
||||
if (caps == null) return@buildJsonArray
|
||||
|
||||
@@ -2,13 +2,19 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.RemoteInput
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
@@ -34,6 +40,28 @@ data class DeviceNotificationSnapshot(
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
enum class NotificationActionKind {
|
||||
Open,
|
||||
Dismiss,
|
||||
Reply,
|
||||
}
|
||||
|
||||
data class NotificationActionRequest(
|
||||
val key: String,
|
||||
val kind: NotificationActionKind,
|
||||
val replyText: String? = null,
|
||||
)
|
||||
|
||||
data class NotificationActionResult(
|
||||
val ok: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
|
||||
return kind == NotificationActionKind.Dismiss
|
||||
}
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
@@ -85,25 +113,71 @@ private object DeviceNotificationStore {
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
activeService = this
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
if (entry.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("posted"))
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
val removed = sbn ?: return
|
||||
val key = removed.key.trim()
|
||||
if (key.isEmpty()) {
|
||||
return
|
||||
}
|
||||
DeviceNotificationStore.remove(key)
|
||||
if (removed.packageName == packageName) {
|
||||
return
|
||||
}
|
||||
emitNotificationsChanged(
|
||||
buildJsonObject {
|
||||
put("change", JsonPrimitive("removed"))
|
||||
put("key", JsonPrimitive(key))
|
||||
val packageName = removed.packageName.trim()
|
||||
if (packageName.isNotEmpty()) {
|
||||
put("packageName", JsonPrimitive(packageName))
|
||||
}
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
@@ -139,10 +213,17 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@Volatile private var nodeEventSink: ((event: String, payloadJson: String?) -> Unit)? = null
|
||||
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
@@ -160,5 +241,125 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
if (!isAccessEnabled(context)) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_DISABLED",
|
||||
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
|
||||
)
|
||||
}
|
||||
val service = activeService
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_UNAVAILABLE",
|
||||
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
|
||||
)
|
||||
return service.executeActionInternal(request)
|
||||
}
|
||||
|
||||
private fun emitNotificationsChanged(payloadJson: String) {
|
||||
runCatching {
|
||||
nodeEventSink?.invoke(NOTIFICATIONS_CHANGED_EVENT, payloadJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
|
||||
val sbn =
|
||||
activeNotifications
|
||||
?.firstOrNull { it.key == request.key }
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
if (actionRequiresClearableNotification(request.kind) && !sbn.isClearable) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_CLEARABLE",
|
||||
message = "NOTIFICATION_NOT_CLEARABLE: notification is ongoing or protected",
|
||||
)
|
||||
}
|
||||
|
||||
return when (request.kind) {
|
||||
NotificationActionKind.Open -> {
|
||||
val pendingIntent = sbn.notification.contentIntent
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no open action",
|
||||
)
|
||||
runCatching {
|
||||
pendingIntent.send()
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "open failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Dismiss -> {
|
||||
runCatching {
|
||||
cancelNotification(sbn.key)
|
||||
DeviceNotificationStore.remove(sbn.key)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "dismiss failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Reply -> {
|
||||
val replyText = request.replyText?.trim().orEmpty()
|
||||
if (replyText.isEmpty()) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
val action =
|
||||
sbn.notification.actions
|
||||
?.firstOrNull { candidate ->
|
||||
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
|
||||
}
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no reply action",
|
||||
)
|
||||
val remoteInputs = action.remoteInputs ?: emptyArray()
|
||||
val fillInIntent = Intent()
|
||||
val replyBundle = android.os.Bundle()
|
||||
for (remoteInput in remoteInputs) {
|
||||
replyBundle.putCharSequence(remoteInput.resultKey, replyText)
|
||||
}
|
||||
RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, replyBundle)
|
||||
runCatching {
|
||||
action.actionIntent.send(this, 0, fillInIntent)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "reply failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
val smsAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
val debugBuild: Boolean,
|
||||
)
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
enum class NodeCapabilityAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
|
||||
data class NodeCapabilitySpec(
|
||||
val name: String,
|
||||
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
|
||||
)
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
@@ -24,6 +56,39 @@ data class InvokeCommandSpec(
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val capabilityManifest: List<NodeCapabilitySpec> =
|
||||
listOf(
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Camera.rawValue,
|
||||
availability = NodeCapabilityAvailability.CameraEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Sms.rawValue,
|
||||
availability = NodeCapabilityAvailability.SmsAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.VoiceWake.rawValue,
|
||||
availability = NodeCapabilityAvailability.VoiceWakeEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Location.rawValue,
|
||||
availability = NodeCapabilityAvailability.LocationEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Motion.rawValue,
|
||||
availability = NodeCapabilityAvailability.MotionAvailable,
|
||||
),
|
||||
)
|
||||
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
@@ -62,6 +127,14 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSystemCommand.Notify.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.List.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
@@ -82,9 +155,41 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Permissions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.Actions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawPhotosCommand.Latest.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Search.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Add.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCalendarCommand.Events.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCalendarCommand.Add.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawMotionCommand.Activity.rawValue,
|
||||
availability = InvokeCommandAvailability.MotionActivityAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawMotionCommand.Pedometer.rawValue,
|
||||
availability = InvokeCommandAvailability.MotionPedometerAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
@@ -104,20 +209,32 @@ object InvokeCommandRegistry {
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> {
|
||||
return capabilityManifest
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
NodeCapabilityAvailability.Always -> true
|
||||
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
@@ -16,6 +21,11 @@ class InvokeDispatcher(
|
||||
private val locationHandler: LocationHandler,
|
||||
private val deviceHandler: DeviceHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val systemHandler: SystemHandler,
|
||||
private val photosHandler: PhotosHandler,
|
||||
private val contactsHandler: ContactsHandler,
|
||||
private val calendarHandler: CalendarHandler,
|
||||
private val motionHandler: MotionHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -26,8 +36,11 @@ class InvokeDispatcher(
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val spec =
|
||||
@@ -112,6 +125,7 @@ class InvokeDispatcher(
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
OpenClawCameraCommand.List.rawValue -> cameraHandler.handleList(paramsJson)
|
||||
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
|
||||
OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson)
|
||||
|
||||
@@ -121,9 +135,30 @@ class InvokeDispatcher(
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleNotificationsActions(paramsJson)
|
||||
|
||||
// System command
|
||||
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
|
||||
|
||||
// Photos command
|
||||
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
|
||||
|
||||
// Contacts command
|
||||
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
|
||||
OpenClawContactsCommand.Add.rawValue -> contactsHandler.handleContactsAdd(paramsJson)
|
||||
|
||||
// Calendar command
|
||||
OpenClawCalendarCommand.Events.rawValue -> calendarHandler.handleCalendarEvents(paramsJson)
|
||||
OpenClawCalendarCommand.Add.rawValue -> calendarHandler.handleCalendarAdd(paramsJson)
|
||||
|
||||
// Motion command
|
||||
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
|
||||
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
@@ -145,17 +180,30 @@ class InvokeDispatcher(
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!readyOnFirstCheck) {
|
||||
if (!refreshNodeCanvasCapability()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
@@ -194,6 +242,24 @@ class InvokeDispatcher(
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.MotionActivityAvailable ->
|
||||
if (motionActivityAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: accelerometer not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.MotionPedometerAvailable ->
|
||||
if (motionPedometerAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PEDOMETER_UNAVAILABLE",
|
||||
message = "PEDOMETER_UNAVAILABLE: step counter not available",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
private const val ACCELEROMETER_SAMPLE_TARGET = 20
|
||||
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
|
||||
|
||||
internal data class MotionActivityRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
internal data class MotionPedometerRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
)
|
||||
|
||||
internal data class MotionActivityRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val confidence: String,
|
||||
val isWalking: Boolean,
|
||||
val isRunning: Boolean,
|
||||
val isCycling: Boolean,
|
||||
val isAutomotive: Boolean,
|
||||
val isStationary: Boolean,
|
||||
val isUnknown: Boolean,
|
||||
)
|
||||
|
||||
internal data class PedometerRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
val steps: Int?,
|
||||
val distanceMeters: Double?,
|
||||
val floorsAscended: Int?,
|
||||
val floorsDescended: Int?,
|
||||
)
|
||||
|
||||
internal interface MotionDataSource {
|
||||
fun isActivityAvailable(context: Context): Boolean
|
||||
|
||||
fun isPedometerAvailable(context: Context): Boolean
|
||||
|
||||
fun isAvailable(context: Context): Boolean = isActivityAvailable(context) || isPedometerAvailable(context)
|
||||
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord
|
||||
|
||||
suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord
|
||||
}
|
||||
|
||||
private object SystemMotionDataSource : MotionDataSource {
|
||||
override fun isActivityAvailable(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
|
||||
}
|
||||
|
||||
override fun isPedometerAvailable(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 29) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: sensor manager unavailable")
|
||||
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: accelerometer not available")
|
||||
|
||||
val sample = readAccelerometerSample(sensorManager, accelerometer)
|
||||
?: throw IllegalStateException("MOTION_UNAVAILABLE: no accelerometer sample")
|
||||
val end = Instant.now()
|
||||
val start = end.minusSeconds(2)
|
||||
val classification = classifyActivity(sample.averageDelta)
|
||||
return MotionActivityRecord(
|
||||
startISO = start.toString(),
|
||||
endISO = end.toString(),
|
||||
confidence = classifyConfidence(sample.samples, sample.averageDelta),
|
||||
isWalking = classification == "walking",
|
||||
isRunning = classification == "running",
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = classification == "stationary",
|
||||
isUnknown = classification == "unknown",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
|
||||
}
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: sensor manager unavailable")
|
||||
val stepCounter = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: step counting not supported")
|
||||
|
||||
val steps = readStepCounter(sensorManager, stepCounter)
|
||||
?: throw IllegalStateException("PEDOMETER_UNAVAILABLE: no step counter sample")
|
||||
val bootMs = System.currentTimeMillis() - SystemClock.elapsedRealtime()
|
||||
return PedometerRecord(
|
||||
startISO = Instant.ofEpochMilli(max(0L, bootMs)).toString(),
|
||||
endISO = Instant.now().toString(),
|
||||
steps = steps,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
)
|
||||
}
|
||||
|
||||
private data class AccelerometerSample(
|
||||
val samples: Int,
|
||||
val averageDelta: Double,
|
||||
)
|
||||
|
||||
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
|
||||
val sample =
|
||||
withTimeoutOrNull(1200L) {
|
||||
suspendCancellableCoroutine<Float?> { cont ->
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (resumed) return
|
||||
val value = event?.values?.firstOrNull()
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(value)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
sensorManager.unregisterListener(listener)
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample?.toInt()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private suspend fun readAccelerometerSample(
|
||||
sensorManager: SensorManager,
|
||||
sensor: Sensor,
|
||||
): AccelerometerSample? {
|
||||
val sample =
|
||||
withTimeoutOrNull(ACCELEROMETER_SAMPLE_TIMEOUT_MS) {
|
||||
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
|
||||
var count = 0
|
||||
var sumDelta = 0.0
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
val values = event?.values ?: return
|
||||
if (values.size < 3) return
|
||||
val magnitude =
|
||||
sqrt(
|
||||
values[0] * values[0] +
|
||||
values[1] * values[1] +
|
||||
values[2] * values[2],
|
||||
).toDouble()
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = if (count == 0) 0.0 else sumDelta / count,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun classifyActivity(averageDelta: Double): String {
|
||||
return when {
|
||||
averageDelta <= 0.55 -> "stationary"
|
||||
averageDelta <= 1.80 -> "walking"
|
||||
else -> "running"
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyConfidence(samples: Int, averageDelta: Double): String {
|
||||
if (samples < 6) return "low"
|
||||
if (samples >= 14 && averageDelta > 0.4) return "high"
|
||||
return "medium"
|
||||
}
|
||||
}
|
||||
|
||||
class MotionHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: MotionDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
|
||||
|
||||
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseActivityRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val activity = dataSource.activity(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"activities",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(activity.startISO))
|
||||
put("endISO", JsonPrimitive(activity.endISO))
|
||||
put("confidence", JsonPrimitive(activity.confidence))
|
||||
put("isWalking", JsonPrimitive(activity.isWalking))
|
||||
put("isRunning", JsonPrimitive(activity.isRunning))
|
||||
put("isCycling", JsonPrimitive(activity.isCycling))
|
||||
put("isAutomotive", JsonPrimitive(activity.isAutomotive))
|
||||
put("isStationary", JsonPrimitive(activity.isStationary))
|
||||
put("isUnknown", JsonPrimitive(activity.isUnknown))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "motion activity failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_PERMISSION_REQUIRED",
|
||||
message = "MOTION_PERMISSION_REQUIRED: grant Motion permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parsePedometerRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val payload = dataSource.pedometer(appContext, request)
|
||||
GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("startISO", JsonPrimitive(payload.startISO))
|
||||
put("endISO", JsonPrimitive(payload.endISO))
|
||||
payload.steps?.let { put("steps", JsonPrimitive(it)) }
|
||||
payload.distanceMeters?.let { put("distanceMeters", JsonPrimitive(it)) }
|
||||
payload.floorsAscended?.let { put("floorsAscended", JsonPrimitive(it)) }
|
||||
payload.floorsDescended?.let { put("floorsDescended", JsonPrimitive(it)) }
|
||||
}.toString(),
|
||||
)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
GatewaySession.InvokeResult.error(code = "MOTION_UNAVAILABLE", message = err.message ?: "MOTION_UNAVAILABLE")
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "MOTION_UNAVAILABLE",
|
||||
message = "MOTION_UNAVAILABLE: ${err.message ?: "pedometer query failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
|
||||
|
||||
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
|
||||
|
||||
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
|
||||
|
||||
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionActivityRequest(startISO = null, endISO = null, limit = 200)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
|
||||
return MotionActivityRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsePedometerRequest(paramsJson: String?): MotionPedometerRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return MotionPedometerRequest(startISO = null, endISO = null)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
return MotionPedometerRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
endISO = (params["endISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
|
||||
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: MotionDataSource,
|
||||
): MotionHandler = MotionHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,20 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
@@ -29,6 +34,10 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
|
||||
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
return DeviceNotificationListenerService.executeAction(context, request)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
@@ -38,11 +47,80 @@ class NotificationsHandler private constructor(
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = readSnapshotWithRebind()
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
readSnapshotWithRebind()
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val key =
|
||||
readString(params, "key")
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: key required",
|
||||
)
|
||||
val actionRaw =
|
||||
readString(params, "action")?.lowercase()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
|
||||
)
|
||||
val action =
|
||||
when (actionRaw) {
|
||||
"open" -> NotificationActionKind.Open
|
||||
"dismiss" -> NotificationActionKind.Dismiss
|
||||
"reply" -> NotificationActionKind.Reply
|
||||
else ->
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action must be open|dismiss|reply",
|
||||
)
|
||||
}
|
||||
val replyText = readString(params, "replyText")
|
||||
if (action == NotificationActionKind.Reply && replyText.isNullOrBlank()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
|
||||
val result =
|
||||
stateProvider.executeAction(
|
||||
appContext,
|
||||
NotificationActionRequest(
|
||||
key = key,
|
||||
kind = action,
|
||||
replyText = replyText,
|
||||
),
|
||||
)
|
||||
if (!result.ok) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = result.code ?: "UNAVAILABLE",
|
||||
message = result.message ?: "notification action failed",
|
||||
)
|
||||
}
|
||||
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("ok", JsonPrimitive(true))
|
||||
put("key", JsonPrimitive(key))
|
||||
put("action", JsonPrimitive(actionRaw))
|
||||
}.toString()
|
||||
return GatewaySession.InvokeResult.ok(payload)
|
||||
}
|
||||
|
||||
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
@@ -72,6 +150,21 @@ class NotificationsHandler private constructor(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readString(params: JsonObject, key: String): String? =
|
||||
(params[key] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.Instant
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_PHOTOS_LIMIT = 1
|
||||
private const val DEFAULT_PHOTOS_MAX_WIDTH = 1600
|
||||
private const val DEFAULT_PHOTOS_QUALITY = 0.85
|
||||
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
|
||||
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
|
||||
|
||||
internal data class PhotosLatestRequest(
|
||||
val limit: Int,
|
||||
val maxWidth: Int,
|
||||
val quality: Double,
|
||||
)
|
||||
|
||||
internal data class EncodedPhotoPayload(
|
||||
val format: String,
|
||||
val base64: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val createdAt: String?,
|
||||
)
|
||||
|
||||
internal interface PhotosDataSource {
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload>
|
||||
}
|
||||
|
||||
private object SystemPhotosDataSource : PhotosDataSource {
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
val permission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
return ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> {
|
||||
val resolver = context.contentResolver
|
||||
val rows = queryLatestRows(resolver, request.limit)
|
||||
if (rows.isEmpty()) return emptyList()
|
||||
|
||||
var remainingBudget = MAX_TOTAL_BASE64_CHARS
|
||||
val out = mutableListOf<EncodedPhotoPayload>()
|
||||
for (row in rows) {
|
||||
if (remainingBudget <= 0) break
|
||||
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private data class PhotoRow(
|
||||
val uri: Uri,
|
||||
val createdAtMs: Long?,
|
||||
)
|
||||
|
||||
private data class EncodedJpeg(
|
||||
val base64: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
)
|
||||
|
||||
private fun queryLatestRows(resolver: ContentResolver, limit: Int): List<PhotoRow> {
|
||||
val projection =
|
||||
arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.DATE_TAKEN,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
)
|
||||
val sortOrder =
|
||||
"${MediaStore.Images.Media.DATE_TAKEN} DESC, ${MediaStore.Images.Media.DATE_ADDED} DESC"
|
||||
val args =
|
||||
Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, sortOrder)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
|
||||
}
|
||||
|
||||
resolver.query(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
args,
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
|
||||
val takenIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
|
||||
val addedIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
|
||||
val rows = mutableListOf<PhotoRow>()
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idIndex)
|
||||
val takenMs = cursor.getLong(takenIndex).takeIf { it > 0L }
|
||||
val addedMs = cursor.getLong(addedIndex).takeIf { it > 0L }?.times(1000L)
|
||||
rows +=
|
||||
PhotoRow(
|
||||
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
|
||||
createdAtMs = takenMs ?: addedMs,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeScaledBitmap(
|
||||
resolver: ContentResolver,
|
||||
uri: Uri,
|
||||
maxWidth: Int,
|
||||
): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, bounds)
|
||||
}
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val inSampleSize = computeInSampleSize(bounds.outWidth, maxWidth)
|
||||
val decodeOptions = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize }
|
||||
val decoded =
|
||||
resolver.openInputStream(uri).use { input ->
|
||||
if (input == null) return null
|
||||
BitmapFactory.decodeStream(input, null, decodeOptions)
|
||||
} ?: return null
|
||||
|
||||
if (decoded.width <= maxWidth) return decoded
|
||||
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
|
||||
return Bitmap.createScaledBitmap(decoded, maxWidth, targetHeight, true)
|
||||
}
|
||||
|
||||
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
|
||||
var sample = 1
|
||||
var candidate = width
|
||||
while (candidate > maxWidth && sample < 64) {
|
||||
sample *= 2
|
||||
candidate = width / sample
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
private fun encodeJpegUnderBudget(
|
||||
bitmap: Bitmap,
|
||||
quality: Double,
|
||||
maxBase64Chars: Int,
|
||||
): EncodedJpeg? {
|
||||
var working = bitmap
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
working = Bitmap.createScaledBitmap(working, nextWidth, nextHeight, true)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class PhotosHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: PhotosDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
|
||||
|
||||
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_PERMISSION_REQUIRED",
|
||||
message = "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
||||
)
|
||||
}
|
||||
val request =
|
||||
parseRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
return try {
|
||||
val photos = dataSource.latest(appContext, request)
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"photos",
|
||||
buildJsonArray {
|
||||
photos.forEach { photo ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("format", JsonPrimitive(photo.format))
|
||||
put("base64", JsonPrimitive(photo.base64))
|
||||
put("width", JsonPrimitive(photo.width))
|
||||
put("height", JsonPrimitive(photo.height))
|
||||
photo.createdAt?.let { put("createdAt", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_UNAVAILABLE",
|
||||
message = "PHOTOS_UNAVAILABLE: ${err.message ?: "photo fetch failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRequest(paramsJson: String?): PhotosLatestRequest? {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return PhotosLatestRequest(
|
||||
limit = DEFAULT_PHOTOS_LIMIT,
|
||||
maxWidth = DEFAULT_PHOTOS_MAX_WIDTH,
|
||||
quality = DEFAULT_PHOTOS_QUALITY,
|
||||
)
|
||||
}
|
||||
val params =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val limitRaw = (params["limit"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
|
||||
|
||||
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
|
||||
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
|
||||
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
|
||||
return PhotosLatestRequest(limit = limit, maxWidth = maxWidth, quality = quality)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: PhotosDataSource,
|
||||
): PhotosHandler = PhotosHandler(appContext = appContext, dataSource = dataSource)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -35,12 +39,13 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
val screenIndex = parseScreenIndex(paramsJson)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val format = parseString(paramsJson, key = "format")
|
||||
val screenIndex = parseScreenIndex(params)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val format = parseString(params, key = "format")
|
||||
if (format != null && format.lowercase() != "mp4") {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
@@ -141,55 +146,38 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFps(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
|
||||
}
|
||||
|
||||
private fun parseString(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
if (!tail.startsWith('\"')) return null
|
||||
val rest = tail.drop(1)
|
||||
val end = rest.indexOf('\"')
|
||||
if (end < 0) return null
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
|
||||
|
||||
internal data class SystemNotifyRequest(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val sound: String?,
|
||||
val priority: String?,
|
||||
)
|
||||
|
||||
internal interface SystemNotificationPoster {
|
||||
fun isAuthorized(): Boolean
|
||||
|
||||
fun post(request: SystemNotifyRequest)
|
||||
}
|
||||
|
||||
private class AndroidSystemNotificationPoster(
|
||||
private val appContext: Context,
|
||||
) : SystemNotificationPoster {
|
||||
override fun isAuthorized(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val granted =
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) return false
|
||||
}
|
||||
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
val channelId = ensureChannel(request.priority)
|
||||
val silent = isSilentSound(request.sound)
|
||||
val notification =
|
||||
NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(request.title)
|
||||
.setContentText(request.body)
|
||||
.setPriority(compatPriority(request.priority))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(silent)
|
||||
.build()
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
throw SecurityException("notifications permission missing")
|
||||
}
|
||||
NotificationManagerCompat.from(appContext).notify((System.currentTimeMillis() and 0x7FFFFFFF).toInt(), notification)
|
||||
}
|
||||
|
||||
private fun ensureChannel(priority: String?): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return NOTIFICATION_CHANNEL_BASE_ID
|
||||
}
|
||||
val normalizedPriority = priority.orEmpty().trim().lowercase()
|
||||
val (suffix, importance, name) =
|
||||
when (normalizedPriority) {
|
||||
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
|
||||
"timesensitive" -> Triple("timesensitive", NotificationManager.IMPORTANCE_HIGH, "OpenClaw Time Sensitive")
|
||||
else -> Triple("active", NotificationManager.IMPORTANCE_DEFAULT, "OpenClaw Active")
|
||||
}
|
||||
val channelId = "$NOTIFICATION_CHANNEL_BASE_ID.$suffix"
|
||||
val manager = appContext.getSystemService(NotificationManager::class.java)
|
||||
val existing = manager.getNotificationChannel(channelId)
|
||||
if (existing == null) {
|
||||
manager.createNotificationChannel(NotificationChannel(channelId, name, importance))
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun compatPriority(priority: String?): Int {
|
||||
return when (priority.orEmpty().trim().lowercase()) {
|
||||
"passive" -> NotificationCompat.PRIORITY_LOW
|
||||
"timesensitive" -> NotificationCompat.PRIORITY_HIGH
|
||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSilentSound(sound: String?): Boolean {
|
||||
val normalized = sound?.trim()?.lowercase() ?: return false
|
||||
return normalized in setOf("none", "silent", "off", "false", "0")
|
||||
}
|
||||
}
|
||||
|
||||
class SystemHandler private constructor(
|
||||
private val poster: SystemNotificationPoster,
|
||||
) {
|
||||
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
|
||||
|
||||
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val params =
|
||||
parseNotifyRequest(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object with title/body",
|
||||
)
|
||||
if (params.title.isEmpty() && params.body.isEmpty()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: empty notification",
|
||||
)
|
||||
}
|
||||
if (!poster.isAuthorized()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NOT_AUTHORIZED",
|
||||
message = "NOT_AUTHORIZED: notifications",
|
||||
)
|
||||
}
|
||||
return try {
|
||||
poster.post(params)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
} catch (_: SecurityException) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NOT_AUTHORIZED",
|
||||
message = "NOT_AUTHORIZED: notifications",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "UNAVAILABLE",
|
||||
message = "NOTIFICATION_FAILED: ${err.message ?: "notification post failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
|
||||
val params = parseParamsObject(paramsJson) ?: return null
|
||||
val rawTitle =
|
||||
(params["title"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?: return null
|
||||
val rawBody =
|
||||
(params["body"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?: return null
|
||||
val sound = (params["sound"] as? JsonPrimitive)?.contentOrNull
|
||||
val priority = (params["priority"] as? JsonPrimitive)?.contentOrNull
|
||||
return SystemNotifyRequest(
|
||||
title = rawTitle.trim(),
|
||||
body = rawBody.trim(),
|
||||
sound = sound?.trim()?.ifEmpty { null },
|
||||
priority = priority?.trim()?.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Device("device"),
|
||||
Notifications("notifications"),
|
||||
System("system"),
|
||||
AppUpdate("appUpdate"),
|
||||
Photos("photos"),
|
||||
Contacts("contacts"),
|
||||
Calendar("calendar"),
|
||||
Motion("motion"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -35,6 +42,7 @@ enum class OpenClawCanvasA2UICommand(val rawValue: String) {
|
||||
}
|
||||
|
||||
enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
List("camera.list"),
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
@@ -74,6 +82,8 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
Status("device.status"),
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
@@ -83,9 +93,58 @@ enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
Actions("notifications.actions"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawSystemCommand(val rawValue: String) {
|
||||
Notify("system.notify"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "system."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawPhotosCommand(val rawValue: String) {
|
||||
Latest("photos.latest"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "photos."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawContactsCommand(val rawValue: String) {
|
||||
Search("contacts.search"),
|
||||
Add("contacts.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "contacts."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawCalendarCommand(val rawValue: String) {
|
||||
Events("calendar.events"),
|
||||
Add("calendar.add"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "calendar."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawMotionCommand(val rawValue: String) {
|
||||
Activity("motion.activity"),
|
||||
Pedometer("motion.pedometer"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "motion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@ package ai.openclaw.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -55,6 +60,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -74,9 +80,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.R
|
||||
import ai.openclaw.android.node.DeviceNotificationListenerService
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
|
||||
@@ -92,6 +102,24 @@ private enum class GatewayInputMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
private enum class PermissionToggle {
|
||||
Discovery,
|
||||
Location,
|
||||
Notifications,
|
||||
Microphone,
|
||||
Camera,
|
||||
Photos,
|
||||
Contacts,
|
||||
Calendar,
|
||||
Motion,
|
||||
Sms,
|
||||
}
|
||||
|
||||
private enum class SpecialAccessToggle {
|
||||
NotificationListener,
|
||||
AppUpdates,
|
||||
}
|
||||
|
||||
private val onboardingBackgroundGradient =
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
@@ -204,53 +232,245 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var gatewayError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var enableDiscovery by rememberSaveable { mutableStateOf(true) }
|
||||
var enableNotifications by rememberSaveable { mutableStateOf(true) }
|
||||
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
||||
var enableSms by rememberSaveable { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
|
||||
val selectedPermissions =
|
||||
remember(
|
||||
context,
|
||||
enableDiscovery,
|
||||
enableNotifications,
|
||||
enableMicrophone,
|
||||
enableCamera,
|
||||
enableSms,
|
||||
smsAvailable,
|
||||
) {
|
||||
val requested = mutableListOf<String>()
|
||||
if (enableDiscovery) {
|
||||
requested += if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
}
|
||||
if (enableNotifications && Build.VERSION.SDK_INT >= 33) requested += Manifest.permission.POST_NOTIFICATIONS
|
||||
if (enableMicrophone) requested += Manifest.permission.RECORD_AUDIO
|
||||
if (enableCamera) requested += Manifest.permission.CAMERA
|
||||
if (enableSms && smsAvailable) requested += Manifest.permission.SEND_SMS
|
||||
requested.filterNot { isPermissionGranted(context, it) }
|
||||
val motionAvailable =
|
||||
remember(context) {
|
||||
hasMotionCapabilities(context)
|
||||
}
|
||||
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
|
||||
val notificationsPermissionRequired = Build.VERSION.SDK_INT >= 33
|
||||
val discoveryPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES
|
||||
} else {
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
}
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
var enableDiscovery by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isPermissionGranted(context, discoveryPermission))
|
||||
}
|
||||
var enableLocation by rememberSaveable { mutableStateOf(false) }
|
||||
var enableNotifications by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
!notificationsPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
|
||||
)
|
||||
}
|
||||
var enableNotificationListener by
|
||||
rememberSaveable {
|
||||
mutableStateOf(isNotificationListenerEnabled(context))
|
||||
}
|
||||
var enableAppUpdates by
|
||||
rememberSaveable {
|
||||
mutableStateOf(canInstallUnknownApps(context))
|
||||
}
|
||||
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCamera by rememberSaveable { mutableStateOf(false) }
|
||||
var enablePhotos by rememberSaveable { mutableStateOf(false) }
|
||||
var enableContacts by rememberSaveable { mutableStateOf(false) }
|
||||
var enableCalendar by rememberSaveable { mutableStateOf(false) }
|
||||
var enableMotion by
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
motionAvailable &&
|
||||
(!motionPermissionRequired || isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)),
|
||||
)
|
||||
}
|
||||
var enableSms by
|
||||
rememberSaveable {
|
||||
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
|
||||
}
|
||||
|
||||
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
|
||||
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
|
||||
|
||||
fun setPermissionToggleEnabled(toggle: PermissionToggle, enabled: Boolean) {
|
||||
when (toggle) {
|
||||
PermissionToggle.Discovery -> enableDiscovery = enabled
|
||||
PermissionToggle.Location -> enableLocation = enabled
|
||||
PermissionToggle.Notifications -> enableNotifications = enabled
|
||||
PermissionToggle.Microphone -> enableMicrophone = enabled
|
||||
PermissionToggle.Camera -> enableCamera = enabled
|
||||
PermissionToggle.Photos -> enablePhotos = enabled
|
||||
PermissionToggle.Contacts -> enableContacts = enabled
|
||||
PermissionToggle.Calendar -> enableCalendar = enabled
|
||||
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
|
||||
PermissionToggle.Sms -> enableSms = enabled && smsAvailable
|
||||
}
|
||||
}
|
||||
|
||||
fun isPermissionToggleGranted(toggle: PermissionToggle): Boolean =
|
||||
when (toggle) {
|
||||
PermissionToggle.Discovery -> isPermissionGranted(context, discoveryPermission)
|
||||
PermissionToggle.Location ->
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
PermissionToggle.Notifications ->
|
||||
!notificationsPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
PermissionToggle.Microphone -> isPermissionGranted(context, Manifest.permission.RECORD_AUDIO)
|
||||
PermissionToggle.Camera -> isPermissionGranted(context, Manifest.permission.CAMERA)
|
||||
PermissionToggle.Photos -> isPermissionGranted(context, photosPermission)
|
||||
PermissionToggle.Contacts ->
|
||||
isPermissionGranted(context, Manifest.permission.READ_CONTACTS) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)
|
||||
PermissionToggle.Calendar ->
|
||||
isPermissionGranted(context, Manifest.permission.READ_CALENDAR) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CALENDAR)
|
||||
PermissionToggle.Motion ->
|
||||
!motionAvailable ||
|
||||
!motionPermissionRequired ||
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
PermissionToggle.Sms ->
|
||||
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
|
||||
}
|
||||
|
||||
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
|
||||
SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val enabledPermissionSummary =
|
||||
remember(enableDiscovery, enableNotifications, enableMicrophone, enableCamera, enableSms, smsAvailable) {
|
||||
remember(
|
||||
enableDiscovery,
|
||||
enableLocation,
|
||||
enableNotifications,
|
||||
enableNotificationListener,
|
||||
enableAppUpdates,
|
||||
enableMicrophone,
|
||||
enableCamera,
|
||||
enablePhotos,
|
||||
enableContacts,
|
||||
enableCalendar,
|
||||
enableMotion,
|
||||
enableSms,
|
||||
smsAvailable,
|
||||
motionAvailable,
|
||||
) {
|
||||
val enabled = mutableListOf<String>()
|
||||
if (enableDiscovery) enabled += "Gateway discovery"
|
||||
if (Build.VERSION.SDK_INT >= 33 && enableNotifications) enabled += "Notifications"
|
||||
if (enableLocation) enabled += "Location"
|
||||
if (enableNotifications) enabled += "Notifications"
|
||||
if (enableNotificationListener) enabled += "Notification listener"
|
||||
if (enableAppUpdates) enabled += "App updates"
|
||||
if (enableMicrophone) enabled += "Microphone"
|
||||
if (enableCamera) enabled += "Camera"
|
||||
if (enablePhotos) enabled += "Photos"
|
||||
if (enableContacts) enabled += "Contacts"
|
||||
if (enableCalendar) enabled += "Calendar"
|
||||
if (enableMotion && motionAvailable) enabled += "Motion"
|
||||
if (smsAvailable && enableSms) enabled += "SMS"
|
||||
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
step = OnboardingStep.FinalCheck
|
||||
val proceedFromPermissions: () -> Unit = proceed@{
|
||||
var openedSpecialSetup = false
|
||||
if (enableNotificationListener && !isNotificationListenerEnabled(context)) {
|
||||
openNotificationListenerSettings(context)
|
||||
openedSpecialSetup = true
|
||||
}
|
||||
if (enableAppUpdates && !canInstallUnknownApps(context)) {
|
||||
openUnknownAppSourcesSettings(context)
|
||||
openedSpecialSetup = true
|
||||
}
|
||||
if (openedSpecialSetup) {
|
||||
return@proceed
|
||||
}
|
||||
step = OnboardingStep.FinalCheck
|
||||
}
|
||||
|
||||
val togglePermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
val pendingToggle = pendingPermissionToggle ?: return@rememberLauncherForActivityResult
|
||||
setPermissionToggleEnabled(pendingToggle, isPermissionToggleGranted(pendingToggle))
|
||||
pendingPermissionToggle = null
|
||||
}
|
||||
|
||||
val requestPermissionToggle: (PermissionToggle, Boolean, List<String>) -> Unit =
|
||||
request@{ toggle, enabled, permissions ->
|
||||
if (!enabled) {
|
||||
setPermissionToggleEnabled(toggle, false)
|
||||
return@request
|
||||
}
|
||||
if (isPermissionToggleGranted(toggle)) {
|
||||
setPermissionToggleEnabled(toggle, true)
|
||||
return@request
|
||||
}
|
||||
val missing = permissions.distinct().filterNot { isPermissionGranted(context, it) }
|
||||
if (missing.isEmpty()) {
|
||||
setPermissionToggleEnabled(toggle, isPermissionToggleGranted(toggle))
|
||||
return@request
|
||||
}
|
||||
pendingPermissionToggle = toggle
|
||||
togglePermissionLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val requestSpecialAccessToggle: (SpecialAccessToggle, Boolean) -> Unit =
|
||||
request@{ toggle, enabled ->
|
||||
if (!enabled) {
|
||||
setSpecialAccessToggleEnabled(toggle, false)
|
||||
pendingSpecialAccessToggle = null
|
||||
return@request
|
||||
}
|
||||
val grantedNow =
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
|
||||
SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
|
||||
}
|
||||
if (grantedNow) {
|
||||
setSpecialAccessToggleEnabled(toggle, true)
|
||||
pendingSpecialAccessToggle = null
|
||||
return@request
|
||||
}
|
||||
pendingSpecialAccessToggle = toggle
|
||||
when (toggle) {
|
||||
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
|
||||
SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, context, pendingSpecialAccessToggle) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_RESUME) {
|
||||
return@LifecycleEventObserver
|
||||
}
|
||||
when (pendingSpecialAccessToggle) {
|
||||
SpecialAccessToggle.NotificationListener -> {
|
||||
setSpecialAccessToggleEnabled(
|
||||
SpecialAccessToggle.NotificationListener,
|
||||
isNotificationListenerEnabled(context),
|
||||
)
|
||||
pendingSpecialAccessToggle = null
|
||||
}
|
||||
SpecialAccessToggle.AppUpdates -> {
|
||||
setSpecialAccessToggleEnabled(
|
||||
SpecialAccessToggle.AppUpdates,
|
||||
canInstallUnknownApps(context),
|
||||
)
|
||||
pendingSpecialAccessToggle = null
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val qrScanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
@@ -382,17 +602,120 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
OnboardingStep.Permissions ->
|
||||
PermissionsStep(
|
||||
enableDiscovery = enableDiscovery,
|
||||
enableLocation = enableLocation,
|
||||
enableNotifications = enableNotifications,
|
||||
enableNotificationListener = enableNotificationListener,
|
||||
enableAppUpdates = enableAppUpdates,
|
||||
enableMicrophone = enableMicrophone,
|
||||
enableCamera = enableCamera,
|
||||
enablePhotos = enablePhotos,
|
||||
enableContacts = enableContacts,
|
||||
enableCalendar = enableCalendar,
|
||||
enableMotion = enableMotion,
|
||||
motionAvailable = motionAvailable,
|
||||
motionPermissionRequired = motionPermissionRequired,
|
||||
enableSms = enableSms,
|
||||
smsAvailable = smsAvailable,
|
||||
context = context,
|
||||
onDiscoveryChange = { enableDiscovery = it },
|
||||
onNotificationsChange = { enableNotifications = it },
|
||||
onMicrophoneChange = { enableMicrophone = it },
|
||||
onCameraChange = { enableCamera = it },
|
||||
onSmsChange = { enableSms = it },
|
||||
onDiscoveryChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Discovery,
|
||||
checked,
|
||||
listOf(discoveryPermission),
|
||||
)
|
||||
},
|
||||
onLocationChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Location,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
},
|
||||
onNotificationsChange = { checked ->
|
||||
if (!notificationsPermissionRequired) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Notifications, checked)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Notifications,
|
||||
checked,
|
||||
listOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
)
|
||||
}
|
||||
},
|
||||
onNotificationListenerChange = { checked ->
|
||||
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
|
||||
},
|
||||
onAppUpdatesChange = { checked ->
|
||||
requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
|
||||
},
|
||||
onMicrophoneChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Microphone,
|
||||
checked,
|
||||
listOf(Manifest.permission.RECORD_AUDIO),
|
||||
)
|
||||
},
|
||||
onCameraChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Camera,
|
||||
checked,
|
||||
listOf(Manifest.permission.CAMERA),
|
||||
)
|
||||
},
|
||||
onPhotosChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Photos,
|
||||
checked,
|
||||
listOf(photosPermission),
|
||||
)
|
||||
},
|
||||
onContactsChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Contacts,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS,
|
||||
),
|
||||
)
|
||||
},
|
||||
onCalendarChange = { checked ->
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Calendar,
|
||||
checked,
|
||||
listOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
),
|
||||
)
|
||||
},
|
||||
onMotionChange = { checked ->
|
||||
if (!motionAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Motion, false)
|
||||
} else if (!motionPermissionRequired) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Motion, checked)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Motion,
|
||||
checked,
|
||||
listOf(Manifest.permission.ACTIVITY_RECOGNITION),
|
||||
)
|
||||
}
|
||||
},
|
||||
onSmsChange = { checked ->
|
||||
if (!smsAvailable) {
|
||||
setPermissionToggleEnabled(PermissionToggle.Sms, false)
|
||||
} else {
|
||||
requestPermissionToggle(
|
||||
PermissionToggle.Sms,
|
||||
checked,
|
||||
listOf(Manifest.permission.SEND_SMS),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
OnboardingStep.FinalCheck ->
|
||||
FinalStep(
|
||||
@@ -504,12 +827,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.setCameraEnabled(enableCamera)
|
||||
viewModel.setLocationMode(if (enableDiscovery) LocationMode.WhileUsing else LocationMode.Off)
|
||||
if (selectedPermissions.isEmpty()) {
|
||||
step = OnboardingStep.FinalCheck
|
||||
} else {
|
||||
permissionLauncher.launch(selectedPermissions.toTypedArray())
|
||||
}
|
||||
viewModel.setLocationMode(if (enableLocation) LocationMode.WhileUsing else LocationMode.Off)
|
||||
proceedFromPermissions()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1014,19 +1333,61 @@ private fun InlineDivider() {
|
||||
@Composable
|
||||
private fun PermissionsStep(
|
||||
enableDiscovery: Boolean,
|
||||
enableLocation: Boolean,
|
||||
enableNotifications: Boolean,
|
||||
enableNotificationListener: Boolean,
|
||||
enableAppUpdates: Boolean,
|
||||
enableMicrophone: Boolean,
|
||||
enableCamera: Boolean,
|
||||
enablePhotos: Boolean,
|
||||
enableContacts: Boolean,
|
||||
enableCalendar: Boolean,
|
||||
enableMotion: Boolean,
|
||||
motionAvailable: Boolean,
|
||||
motionPermissionRequired: Boolean,
|
||||
enableSms: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
context: Context,
|
||||
onDiscoveryChange: (Boolean) -> Unit,
|
||||
onLocationChange: (Boolean) -> Unit,
|
||||
onNotificationsChange: (Boolean) -> Unit,
|
||||
onNotificationListenerChange: (Boolean) -> Unit,
|
||||
onAppUpdatesChange: (Boolean) -> Unit,
|
||||
onMicrophoneChange: (Boolean) -> Unit,
|
||||
onCameraChange: (Boolean) -> Unit,
|
||||
onPhotosChange: (Boolean) -> Unit,
|
||||
onContactsChange: (Boolean) -> Unit,
|
||||
onCalendarChange: (Boolean) -> Unit,
|
||||
onMotionChange: (Boolean) -> Unit,
|
||||
onSmsChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
|
||||
val locationGranted =
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
isPermissionGranted(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
val contactsGranted =
|
||||
isPermissionGranted(context, Manifest.permission.READ_CONTACTS) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CONTACTS)
|
||||
val calendarGranted =
|
||||
isPermissionGranted(context, Manifest.permission.READ_CALENDAR) &&
|
||||
isPermissionGranted(context, Manifest.permission.WRITE_CALENDAR)
|
||||
val motionGranted =
|
||||
if (!motionAvailable) {
|
||||
false
|
||||
} else if (!motionPermissionRequired) {
|
||||
true
|
||||
} else {
|
||||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
val notificationListenerGranted = isNotificationListenerEnabled(context)
|
||||
val appUpdatesGranted = canInstallUnknownApps(context)
|
||||
|
||||
StepShell(title = "Permissions") {
|
||||
Text(
|
||||
"Enable only what you need now. You can change everything later in Settings.",
|
||||
@@ -1041,16 +1402,40 @@ private fun PermissionsStep(
|
||||
onCheckedChange = onDiscoveryChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Location",
|
||||
subtitle = "location.get (while app is open unless set to Always later)",
|
||||
checked = enableLocation,
|
||||
granted = locationGranted,
|
||||
onCheckedChange = onLocationChange,
|
||||
)
|
||||
InlineDivider()
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
PermissionToggleRow(
|
||||
title = "Notifications",
|
||||
subtitle = "Foreground service + alerts",
|
||||
subtitle = "system.notify and foreground alerts",
|
||||
checked = enableNotifications,
|
||||
granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
|
||||
onCheckedChange = onNotificationsChange,
|
||||
)
|
||||
InlineDivider()
|
||||
}
|
||||
PermissionToggleRow(
|
||||
title = "Notification listener",
|
||||
subtitle = "notifications.list and notifications.actions (opens Android Settings)",
|
||||
checked = enableNotificationListener,
|
||||
granted = notificationListenerGranted,
|
||||
onCheckedChange = onNotificationListenerChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "App updates",
|
||||
subtitle = "app.update install confirmation (opens Android Settings)",
|
||||
checked = enableAppUpdates,
|
||||
granted = appUpdatesGranted,
|
||||
onCheckedChange = onAppUpdatesChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Voice tab transcription",
|
||||
@@ -1066,6 +1451,40 @@ private fun PermissionsStep(
|
||||
granted = isPermissionGranted(context, Manifest.permission.CAMERA),
|
||||
onCheckedChange = onCameraChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Photos",
|
||||
subtitle = "photos.latest",
|
||||
checked = enablePhotos,
|
||||
granted = isPermissionGranted(context, photosPermission),
|
||||
onCheckedChange = onPhotosChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Contacts",
|
||||
subtitle = "contacts.search and contacts.add",
|
||||
checked = enableContacts,
|
||||
granted = contactsGranted,
|
||||
onCheckedChange = onContactsChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Calendar",
|
||||
subtitle = "calendar.events and calendar.add",
|
||||
checked = enableCalendar,
|
||||
granted = calendarGranted,
|
||||
onCheckedChange = onCalendarChange,
|
||||
)
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Motion",
|
||||
subtitle = "motion.activity and motion.pedometer",
|
||||
checked = enableMotion,
|
||||
granted = motionGranted,
|
||||
onCheckedChange = onMotionChange,
|
||||
enabled = motionAvailable,
|
||||
statusOverride = if (!motionAvailable) "Unavailable on this device" else null,
|
||||
)
|
||||
if (smsAvailable) {
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
@@ -1086,6 +1505,8 @@ private fun PermissionToggleRow(
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
granted: Boolean,
|
||||
enabled: Boolean = true,
|
||||
statusOverride: String? = null,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -1097,7 +1518,7 @@ private fun PermissionToggleRow(
|
||||
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
Text(
|
||||
if (granted) "Granted" else "Not granted",
|
||||
statusOverride ?: if (granted) "Granted" else "Not granted",
|
||||
style = onboardingCaption1Style,
|
||||
color = if (granted) onboardingSuccess else onboardingTextSecondary,
|
||||
)
|
||||
@@ -1105,6 +1526,7 @@ private fun PermissionToggleRow(
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
@@ -1141,8 +1563,8 @@ private fun FinalStep(
|
||||
} else {
|
||||
GuideBlock(title = "Pairing Required") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw nodes pending")
|
||||
CommandBlock("openclaw nodes approve <requestId>")
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
@@ -1207,3 +1629,50 @@ private fun Bullet(text: String) {
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 26) return true
|
||||
return context.packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
private fun openNotificationListenerSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUnknownAppSourcesSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -68,6 +69,11 @@ private enum class StatusVisual {
|
||||
fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var activeTab by rememberSaveable { mutableStateOf(HomeTab.Connect) }
|
||||
|
||||
// Stop TTS when user navigates away from voice tab
|
||||
LaunchedEffect(activeTab) {
|
||||
viewModel.setVoiceScreenActive(activeTab == HomeTab.Voice)
|
||||
}
|
||||
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -66,6 +68,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.node.DeviceNotificationListenerService
|
||||
|
||||
@Composable
|
||||
fun SettingsSheet(viewModel: MainViewModel) {
|
||||
@@ -162,6 +165,91 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
remember {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
val motionPermissionRequired = Build.VERSION.SDK_INT >= 29
|
||||
val motionAvailable = remember(context) { hasMotionCapabilities(context) }
|
||||
|
||||
var notificationsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(hasNotificationsPermission(context))
|
||||
}
|
||||
val notificationsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
notificationsPermissionGranted = granted
|
||||
}
|
||||
|
||||
var notificationListenerEnabled by
|
||||
remember {
|
||||
mutableStateOf(isNotificationListenerEnabled(context))
|
||||
}
|
||||
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val photosPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
photosPermissionGranted = granted
|
||||
}
|
||||
|
||||
var contactsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val contactsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val readOk = perms[Manifest.permission.READ_CONTACTS] == true
|
||||
val writeOk = perms[Manifest.permission.WRITE_CONTACTS] == true
|
||||
contactsPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var calendarPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val calendarPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val readOk = perms[Manifest.permission.READ_CALENDAR] == true
|
||||
val writeOk = perms[Manifest.permission.WRITE_CALENDAR] == true
|
||||
calendarPermissionGranted = readOk && writeOk
|
||||
}
|
||||
|
||||
var motionPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val motionPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
motionPermissionGranted = granted
|
||||
}
|
||||
|
||||
var appUpdateInstallEnabled by
|
||||
remember {
|
||||
mutableStateOf(canInstallUnknownApps(context))
|
||||
}
|
||||
|
||||
var smsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
@@ -182,6 +270,26 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
micPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
notificationsPermissionGranted = hasNotificationsPermission(context)
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
photosPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
contactsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
calendarPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
motionPermissionGranted =
|
||||
!motionPermissionRequired ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
appUpdateInstallEnabled = canInstallUnknownApps(context)
|
||||
smsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -437,6 +545,254 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Notifications
|
||||
item {
|
||||
Text(
|
||||
"NOTIFICATIONS",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val buttonLabel =
|
||||
if (notificationsPermissionGranted) {
|
||||
"Manage"
|
||||
} else {
|
||||
"Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("System Notifications", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `system.notify` and Android foreground service alerts.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `notifications.list` and `notifications.actions`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationListenerEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Data access
|
||||
item {
|
||||
Text(
|
||||
"DATA ACCESS",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `photos.latest`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Contacts Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `contacts.search` and `contacts.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (contactsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (contactsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Calendar Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `calendar.events` and `calendar.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (calendarPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (calendarPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
val motionButtonLabel =
|
||||
when {
|
||||
!motionAvailable -> "Unavailable"
|
||||
!motionPermissionRequired -> "Manage"
|
||||
motionPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Motion Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!motionAvailable) {
|
||||
"This device does not expose accelerometer or step-counter motion sensors."
|
||||
} else {
|
||||
"Required for `motion.activity` and `motion.pedometer`."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (!motionAvailable) return@Button
|
||||
if (!motionPermissionRequired || motionPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
},
|
||||
enabled = motionAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// System
|
||||
item {
|
||||
Text(
|
||||
"SYSTEM",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Install App Updates", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Enable install access for `app.update` package installs.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openUnknownAppSourcesSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (appUpdateInstallEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Location
|
||||
item {
|
||||
Text(
|
||||
@@ -603,3 +959,50 @@ private fun openAppSettings(context: Context) {
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun openNotificationListenerSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUnknownAppSourcesSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
openAppSettings(context)
|
||||
return
|
||||
}
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
)
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
}.getOrElse {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 33) return true
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
||||
private fun canInstallUnknownApps(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < 26) return true
|
||||
return context.packageManager.canRequestPackageInstalls()
|
||||
}
|
||||
|
||||
private fun hasMotionCapabilities(context: Context): Boolean {
|
||||
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
|
||||
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
|
||||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
}
|
||||
|
||||
@@ -10,12 +10,6 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,14 +21,11 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -44,9 +35,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -74,9 +69,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.android.MainViewModel
|
||||
import ai.openclaw.android.voice.VoiceConversationEntry
|
||||
import ai.openclaw.android.voice.VoiceConversationRole
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
@@ -85,9 +78,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val activity = remember(context) { context.findActivity() }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
@@ -109,7 +103,11 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
// Stop TTS when leaving the voice screen
|
||||
viewModel.setVoiceScreenActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
val requestMicPermission =
|
||||
@@ -138,33 +136,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"VOICE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
Text("Voice mode", style = mobileTitle2, color = mobileText)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (isConnected) mobileAccentSoft else mobileSurfaceStrong,
|
||||
border = BorderStroke(1.dp, if (isConnected) mobileAccent.copy(alpha = 0.25f) else mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
if (isConnected) "Connected" else "Offline",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = mobileCaption1,
|
||||
color = if (isConnected) mobileAccent else mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
@@ -173,15 +144,31 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"Tap the mic and speak. Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = mobileTextTertiary,
|
||||
)
|
||||
Text(
|
||||
"Tap the mic to start",
|
||||
style = mobileHeadline,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,122 +184,144 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
|
||||
) {
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
micLiveTranscript!!.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mic button with input-reactive ring + speaker toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Speaker toggle
|
||||
IconButton(
|
||||
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileAccent.copy(alpha = 0.2f)),
|
||||
// Ring size = 68dp base + up to 22dp driven by mic input level.
|
||||
// The outer Box is fixed at 90dp (max ring size) so the ring never shifts the button.
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 16.dp).size(90.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (micEnabled) {
|
||||
val ringLevel = micInputLevel.coerceIn(0f, 1f)
|
||||
val ringSize = 68.dp + (22.dp * max(ringLevel, 0.05f))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ringSize)
|
||||
.background(mobileAccent.copy(alpha = 0.12f + 0.14f * ringLevel), CircleShape),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (micCooldown) return@Button
|
||||
if (micEnabled) {
|
||||
viewModel.setMicEnabled(false)
|
||||
return@Button
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
enabled = !micCooldown,
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(60.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (micCooldown) mobileTextSecondary else if (micEnabled) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = mobileTextSecondary,
|
||||
disabledContentColor = Color.White.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
micLiveTranscript!!.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout,
|
||||
color = mobileText,
|
||||
Icon(
|
||||
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MicWaveform(level = micInputLevel, active = micEnabled)
|
||||
// Invisible spacer to balance the row (same size as speaker button)
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (micEnabled) {
|
||||
viewModel.setMicEnabled(false)
|
||||
return@Button
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(86.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (micEnabled) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (micEnabled) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = if (micEnabled) "Turn microphone off" else "Turn microphone on",
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
// Status + labels
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micCooldown -> "Cooldown"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
Text(
|
||||
if (micEnabled) "Tap to stop" else "Tap to speak",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
if (activity == null) {
|
||||
false
|
||||
} else {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
Text(
|
||||
if (showRationale) {
|
||||
"Microphone permission is required for voice mode."
|
||||
} else {
|
||||
"Microphone blocked. Open app settings to enable it."
|
||||
},
|
||||
style = mobileCaption1,
|
||||
color = mobileWarning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = { openAppSettings(context) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
|
||||
) {
|
||||
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
if (activity == null) {
|
||||
false
|
||||
} else {
|
||||
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
micStatusText,
|
||||
if (showRationale) {
|
||||
"Microphone permission is required for voice mode."
|
||||
} else {
|
||||
"Microphone blocked. Open app settings to enable it."
|
||||
},
|
||||
style = mobileCaption1,
|
||||
color = mobileTextTertiary,
|
||||
color = mobileWarning,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
onClick = { openAppSettings(context) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = mobileSurfaceStrong, contentColor = mobileText),
|
||||
) {
|
||||
Text("Open settings", style = mobileCallout.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,18 +336,18 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isUser) mobileAccentSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent.copy(alpha = 0.2f) else mobileBorder),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isUser) mobileAccentSoft else Color.White,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
if (isUser) "You" else "OpenClaw",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
style = mobileCaption2.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
|
||||
color = if (isUser) mobileAccent else mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
if (entry.isStreaming && entry.text.isBlank()) "Listening response…" else entry.text,
|
||||
@@ -355,12 +364,12 @@ private fun VoiceThinkingBubble() {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -389,44 +398,6 @@ private fun ThinkingDot(alpha: Float, color: Color) {
|
||||
) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MicWaveform(level: Float, active: Boolean) {
|
||||
val transition = rememberInfiniteTransition(label = "voiceWave")
|
||||
val phase by
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(1_000, easing = LinearEasing), repeatMode = RepeatMode.Restart),
|
||||
label = "voiceWavePhase",
|
||||
)
|
||||
|
||||
val effective = if (active) level.coerceIn(0f, 1f) else 0f
|
||||
val base = max(effective, if (active) 0.05f else 0f)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(16) { index ->
|
||||
val pulse =
|
||||
if (!active) {
|
||||
0f
|
||||
} else {
|
||||
((sin(((phase * 2f * PI) + (index * 0.55f)).toDouble()) + 1.0) * 0.5).toFloat()
|
||||
}
|
||||
val barHeight = 6.dp + (24.dp * (base * pulse))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(5.dp)
|
||||
.height(barHeight)
|
||||
.background(if (active) mobileAccent else mobileBorderStrong, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
package ai.openclaw.android.voice
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Streams text chunks to ElevenLabs WebSocket API and plays audio in real-time.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Create instance with voice/API config
|
||||
* 2. Call [start] to open WebSocket + AudioTrack
|
||||
* 3. Call [sendText] with incremental text chunks as they arrive
|
||||
* 4. Call [finish] when the full response is ready (sends EOS to ElevenLabs)
|
||||
* 5. Call [stop] to cancel/cleanup at any time
|
||||
*
|
||||
* Audio playback begins as soon as the first audio chunk arrives from ElevenLabs,
|
||||
* typically within ~100ms of the first text chunk for eleven_flash_v2_5.
|
||||
*
|
||||
* Note: eleven_v3 does NOT support WebSocket streaming. Use eleven_flash_v2_5
|
||||
* or eleven_flash_v2 for lowest latency.
|
||||
*/
|
||||
class ElevenLabsStreamingTts(
|
||||
private val scope: CoroutineScope,
|
||||
private val voiceId: String,
|
||||
private val apiKey: String,
|
||||
private val modelId: String = "eleven_flash_v2_5",
|
||||
private val outputFormat: String = "pcm_24000",
|
||||
private val sampleRate: Int = 24000,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElevenLabsStreamTTS"
|
||||
private const val BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech"
|
||||
|
||||
/** Models that support WebSocket input streaming */
|
||||
val STREAMING_MODELS = setOf(
|
||||
"eleven_flash_v2_5",
|
||||
"eleven_flash_v2",
|
||||
"eleven_multilingual_v2",
|
||||
"eleven_turbo_v2_5",
|
||||
"eleven_turbo_v2",
|
||||
"eleven_monolingual_v1",
|
||||
)
|
||||
|
||||
fun supportsStreaming(modelId: String): Boolean = modelId in STREAMING_MODELS
|
||||
}
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var trackStarted = false
|
||||
private var client: OkHttpClient? = null
|
||||
@Volatile private var stopped = false
|
||||
@Volatile private var finished = false
|
||||
@Volatile var hasReceivedAudio = false
|
||||
private set
|
||||
private var drainJob: Job? = null
|
||||
|
||||
// Track text already sent so we only send incremental chunks
|
||||
private var sentTextLength = 0
|
||||
@Volatile private var wsReady = false
|
||||
private val pendingText = mutableListOf<String>()
|
||||
|
||||
/**
|
||||
* Open the WebSocket connection and prepare AudioTrack.
|
||||
* Must be called before [sendText].
|
||||
*/
|
||||
fun start() {
|
||||
stopped = false
|
||||
finished = false
|
||||
hasReceivedAudio = false
|
||||
sentTextLength = 0
|
||||
trackStarted = false
|
||||
wsReady = false
|
||||
sentFullText = ""
|
||||
synchronized(pendingText) { pendingText.clear() }
|
||||
|
||||
// Prepare AudioTrack
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val bufferSize = max(minBuffer * 2, 8 * 1024)
|
||||
val track = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build(),
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE,
|
||||
)
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
track.release()
|
||||
Log.e(TAG, "AudioTrack init failed")
|
||||
return
|
||||
}
|
||||
audioTrack = track
|
||||
_isPlaying.value = true
|
||||
|
||||
// Open WebSocket
|
||||
val url = "$BASE_URL/$voiceId/stream-input?model_id=$modelId&output_format=$outputFormat"
|
||||
val okClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
client = okClient
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("xi-api-key", apiKey)
|
||||
.build()
|
||||
|
||||
webSocket = okClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "WebSocket connected")
|
||||
// Send initial config with voice settings
|
||||
val config = JSONObject().apply {
|
||||
put("text", " ")
|
||||
put("voice_settings", JSONObject().apply {
|
||||
put("stability", 0.5)
|
||||
put("similarity_boost", 0.8)
|
||||
put("use_speaker_boost", false)
|
||||
})
|
||||
put("generation_config", JSONObject().apply {
|
||||
put("chunk_length_schedule", org.json.JSONArray(listOf(120, 160, 250, 290)))
|
||||
})
|
||||
}
|
||||
webSocket.send(config.toString())
|
||||
wsReady = true
|
||||
// Flush any text that was queued before WebSocket was ready
|
||||
synchronized(pendingText) {
|
||||
for (queued in pendingText) {
|
||||
val msg = JSONObject().apply { put("text", queued) }
|
||||
webSocket.send(msg.toString())
|
||||
Log.d(TAG, "flushed queued chunk: ${queued.length} chars")
|
||||
}
|
||||
pendingText.clear()
|
||||
}
|
||||
// Send deferred EOS if finish() was called before WebSocket was ready
|
||||
if (finished) {
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
webSocket.send(eos.toString())
|
||||
Log.d(TAG, "sent deferred EOS")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (stopped) return
|
||||
try {
|
||||
val json = JSONObject(text)
|
||||
val audio = json.optString("audio", "")
|
||||
if (audio.isNotEmpty()) {
|
||||
val pcmBytes = Base64.decode(audio, Base64.DEFAULT)
|
||||
writeToTrack(pcmBytes)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing WebSocket message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "WebSocket error: ${t.message}")
|
||||
stopped = true
|
||||
cleanup()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed: $code $reason")
|
||||
// Wait for AudioTrack to finish playing buffered audio, then cleanup
|
||||
drainJob = scope.launch(Dispatchers.IO) {
|
||||
drainAudioTrack()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send incremental text. Call with the full accumulated text so far —
|
||||
* only the new portion (since last send) will be transmitted.
|
||||
*/
|
||||
// Track the full text we've sent so we can detect replacement vs append
|
||||
private var sentFullText = ""
|
||||
|
||||
/**
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
* Returns true if text was accepted, false if text diverged (caller should restart).
|
||||
*/
|
||||
@Synchronized
|
||||
fun sendText(fullText: String): Boolean {
|
||||
if (stopped) return false
|
||||
if (finished) return true // Already finishing — not a diverge, don't restart
|
||||
|
||||
// Detect text replacement: if the new text doesn't start with what we already sent,
|
||||
// the stream has diverged (e.g., tool call interrupted and text was replaced).
|
||||
if (sentFullText.isNotEmpty() && !fullText.startsWith(sentFullText)) {
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
Log.d(TAG, "text diverged — sent='${sentFullText.take(60)}' new='${fullText.take(60)}'")
|
||||
return false
|
||||
}
|
||||
|
||||
if (fullText.length > sentTextLength) {
|
||||
val newText = fullText.substring(sentTextLength)
|
||||
sentTextLength = fullText.length
|
||||
sentFullText = fullText
|
||||
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
val msg = JSONObject().apply { put("text", newText) }
|
||||
ws.send(msg.toString())
|
||||
Log.d(TAG, "sent chunk: ${newText.length} chars")
|
||||
} else {
|
||||
// Queue if WebSocket not connected yet (ws null = still connecting, wsReady false = handshake pending)
|
||||
synchronized(pendingText) { pendingText.add(newText) }
|
||||
Log.d(TAG, "queued chunk: ${newText.length} chars (ws not ready)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that no more text is coming. Sends EOS to ElevenLabs.
|
||||
* The WebSocket will close after generating remaining audio.
|
||||
*/
|
||||
@Synchronized
|
||||
fun finish() {
|
||||
if (stopped || finished) return
|
||||
finished = true
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
// Send empty text to signal end of stream
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
ws.send(eos.toString())
|
||||
Log.d(TAG, "sent EOS")
|
||||
}
|
||||
// else: WebSocket not ready yet; onOpen will send EOS after flushing queued text
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop playback and close everything.
|
||||
*/
|
||||
fun stop() {
|
||||
stopped = true
|
||||
finished = true
|
||||
drainJob?.cancel()
|
||||
drainJob = null
|
||||
webSocket?.cancel()
|
||||
webSocket = null
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.pause()
|
||||
track.flush()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
|
||||
private fun writeToTrack(pcmBytes: ByteArray) {
|
||||
val track = audioTrack ?: return
|
||||
if (stopped) return
|
||||
|
||||
// Start playback on first audio chunk — avoids underrun
|
||||
if (!trackStarted) {
|
||||
track.play()
|
||||
trackStarted = true
|
||||
hasReceivedAudio = true
|
||||
Log.d(TAG, "AudioTrack started on first chunk")
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while (offset < pcmBytes.size && !stopped) {
|
||||
val wrote = track.write(pcmBytes, offset, pcmBytes.size - offset)
|
||||
if (wrote <= 0) {
|
||||
if (stopped) return
|
||||
Log.w(TAG, "AudioTrack write returned $wrote")
|
||||
break
|
||||
}
|
||||
offset += wrote
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainAudioTrack() {
|
||||
if (stopped) return
|
||||
// Wait up to 10s for audio to finish playing
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (!stopped && System.currentTimeMillis() < deadline) {
|
||||
// Check if track is still playing
|
||||
val track = audioTrack ?: return
|
||||
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) return
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.stop()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
@@ -38,9 +39,16 @@ data class VoiceConversationEntry(
|
||||
class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val sendToGateway: suspend (String) -> String?,
|
||||
/**
|
||||
* Send [message] to the gateway and return the run ID.
|
||||
* [onRunIdKnown] is called with the idempotency key *before* the network
|
||||
* round-trip so [pendingRunId] is set before any chat events can arrive.
|
||||
*/
|
||||
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
|
||||
private val speakAssistantReply: suspend (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "MicCapture"
|
||||
private const val speechMinSessionMs = 30_000L
|
||||
private const val speechCompleteSilenceMs = 1_500L
|
||||
private const val speechPossibleSilenceMs = 900L
|
||||
@@ -48,16 +56,15 @@ class MicCaptureManager(
|
||||
private const val pendingRunTimeoutMs = 45_000L
|
||||
}
|
||||
|
||||
private data class QueuedUtterance(
|
||||
val text: String,
|
||||
)
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _micEnabled = MutableStateFlow(false)
|
||||
val micEnabled: StateFlow<Boolean> = _micEnabled
|
||||
|
||||
private val _micCooldown = MutableStateFlow(false)
|
||||
val micCooldown: StateFlow<Boolean> = _micCooldown
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening: StateFlow<Boolean> = _isListening
|
||||
|
||||
@@ -79,7 +86,7 @@ class MicCaptureManager(
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
val isSending: StateFlow<Boolean> = _isSending
|
||||
|
||||
private val messageQueue = ArrayDeque<QueuedUtterance>()
|
||||
private val messageQueue = ArrayDeque<String>()
|
||||
private val sessionSegments = mutableListOf<String>()
|
||||
private var lastFinalSegment: String? = null
|
||||
private var pendingRunId: String? = null
|
||||
@@ -88,6 +95,7 @@ class MicCaptureManager(
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var drainJob: Job? = null
|
||||
private var pendingRunTimeoutJob: Job? = null
|
||||
private var stopRequested = false
|
||||
|
||||
@@ -98,9 +106,23 @@ class MicCaptureManager(
|
||||
start()
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
stop()
|
||||
flushSessionToQueue()
|
||||
sendQueuedIfIdle()
|
||||
// Give the recognizer time to finish processing buffered audio.
|
||||
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
|
||||
drainJob?.cancel()
|
||||
_micCooldown.value = true
|
||||
drainJob = scope.launch {
|
||||
delay(2000L)
|
||||
stop()
|
||||
// Capture any partial transcript that didn't get a final result from the recognizer
|
||||
val partial = _liveTranscript.value?.trim().orEmpty()
|
||||
if (partial.isNotEmpty() && sessionSegments.isEmpty()) {
|
||||
sessionSegments.add(partial)
|
||||
}
|
||||
flushSessionToQueue()
|
||||
drainJob = null
|
||||
_micCooldown.value = false
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +147,9 @@ class MicCaptureManager(
|
||||
null
|
||||
} ?: return
|
||||
|
||||
val runId = pendingRunId ?: return
|
||||
val runId = pendingRunId ?: run { Log.d("MicCapture", "no pendingRunId — drop"); return }
|
||||
val eventRunId = payload["runId"].asStringOrNull() ?: return
|
||||
if (eventRunId != runId) return
|
||||
if (eventRunId != runId) { Log.d("MicCapture", "runId mismatch: event=$eventRunId pending=$runId"); return }
|
||||
|
||||
when (payload["state"].asStringOrNull()) {
|
||||
"delta" -> {
|
||||
@@ -140,6 +162,7 @@ class MicCaptureManager(
|
||||
val finalText = parseAssistantText(payload)?.trim().orEmpty()
|
||||
if (finalText.isNotEmpty()) {
|
||||
upsertPendingAssistant(text = finalText, isStreaming = false)
|
||||
playAssistantReplyAsync(finalText)
|
||||
} else if (pendingAssistantEntryId != null) {
|
||||
updateConversationEntry(pendingAssistantEntryId!!, text = null, isStreaming = false)
|
||||
}
|
||||
@@ -241,7 +264,11 @@ class MicCaptureManager(
|
||||
}
|
||||
|
||||
private fun flushSessionToQueue() {
|
||||
val message = sessionSegments.joinToString(" ").trim()
|
||||
// Add sentence-ending punctuation between recognizer segments to avoid run-on text
|
||||
val message = sessionSegments.joinToString(". ") { segment ->
|
||||
val trimmed = segment.trimEnd()
|
||||
if (trimmed.isNotEmpty() && trimmed.last() in ".!?,;:") trimmed else trimmed
|
||||
}.trim().let { if (it.isNotEmpty() && it.last() !in ".!?") "$it." else it }
|
||||
sessionSegments.clear()
|
||||
_liveTranscript.value = null
|
||||
lastFinalSegment = null
|
||||
@@ -251,12 +278,12 @@ class MicCaptureManager(
|
||||
role = VoiceConversationRole.User,
|
||||
text = message,
|
||||
)
|
||||
messageQueue.addLast(QueuedUtterance(text = message))
|
||||
messageQueue.addLast(message)
|
||||
publishQueue()
|
||||
}
|
||||
|
||||
private fun publishQueue() {
|
||||
_queuedMessages.value = messageQueue.map { it.text }
|
||||
_queuedMessages.value = messageQueue.toList()
|
||||
}
|
||||
|
||||
private fun sendQueuedIfIdle() {
|
||||
@@ -282,8 +309,13 @@ class MicCaptureManager(
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val runId = sendToGateway(next.text)
|
||||
pendingRunId = runId
|
||||
val runId = sendToGateway(next) { earlyRunId ->
|
||||
// Called with the idempotency key before chat.send fires so that
|
||||
// pendingRunId is populated before any chat events can arrive.
|
||||
pendingRunId = earlyRunId
|
||||
}
|
||||
// Update to the real runId if the gateway returned a different one.
|
||||
if (runId != null && runId != pendingRunId) pendingRunId = runId
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
@@ -361,15 +393,21 @@ class MicCaptureManager(
|
||||
|
||||
private fun updateConversationEntry(id: String, text: String?, isStreaming: Boolean) {
|
||||
val current = _conversation.value
|
||||
_conversation.value =
|
||||
current.map { entry ->
|
||||
if (entry.id == id) {
|
||||
val updatedText = text ?: entry.text
|
||||
entry.copy(text = updatedText, isStreaming = isStreaming)
|
||||
} else {
|
||||
entry
|
||||
}
|
||||
if (current.isEmpty()) return
|
||||
|
||||
val targetIndex =
|
||||
when {
|
||||
current[current.lastIndex].id == id -> current.lastIndex
|
||||
else -> current.indexOfFirst { it.id == id }
|
||||
}
|
||||
if (targetIndex < 0) return
|
||||
|
||||
val entry = current[targetIndex]
|
||||
val updatedText = text ?: entry.text
|
||||
if (updatedText == entry.text && entry.isStreaming == isStreaming) return
|
||||
val updated = current.toMutableList()
|
||||
updated[targetIndex] = entry.copy(text = updatedText, isStreaming = isStreaming)
|
||||
_conversation.value = updated
|
||||
}
|
||||
|
||||
private fun upsertPendingAssistant(text: String, isStreaming: Boolean) {
|
||||
@@ -386,6 +424,18 @@ class MicCaptureManager(
|
||||
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
|
||||
}
|
||||
|
||||
private fun playAssistantReplyAsync(text: String) {
|
||||
val spoken = text.trim()
|
||||
if (spoken.isEmpty()) return
|
||||
scope.launch {
|
||||
try {
|
||||
speakAssistantReply(spoken)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "assistant speech failed: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFinalTranscript(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
@@ -499,8 +549,8 @@ class MicCaptureManager(
|
||||
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
onFinalTranscript(text)
|
||||
flushSessionToQueue()
|
||||
sendQueuedIfIdle()
|
||||
// Don't auto-send on silence — accumulate transcript.
|
||||
// Send happens when mic is toggled off (setMicEnabled(false)).
|
||||
}
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -439,4 +439,128 @@ class GatewaySessionInvokeTest {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewaySessionInvokeTimeoutTest {
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(0L))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesInvokeBudgetWithinBounds() {
|
||||
assertEquals(30_000L, resolveInvokeResultAckTimeoutMs(30_000L))
|
||||
assertEquals(90_000L, resolveInvokeResultAckTimeoutMs(90_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_capsAtUpperBound() {
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesTerminalCapabilitySegment() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesWhenQueryAndFragmentPresent() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token?a=1#frag",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token?a=1#frag",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CalendarHandlerTest {
|
||||
@Test
|
||||
fun handleCalendarEvents_requiresPermission() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleCalendarEvents(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarAdd_rejectsEndBeforeStart() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = true, canWrite = true))
|
||||
|
||||
val result =
|
||||
handler.handleCalendarAdd(
|
||||
"""{"title":"Standup","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T09:00:00Z"}""",
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_INVALID", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarEvents_returnsEvents() {
|
||||
val event =
|
||||
CalendarEventRecord(
|
||||
identifier = "101",
|
||||
title = "Sprint Planning",
|
||||
startISO = "2026-02-28T10:00:00Z",
|
||||
endISO = "2026-02-28T11:00:00Z",
|
||||
isAllDay = false,
|
||||
location = "Room 1",
|
||||
calendarTitle = "Work",
|
||||
)
|
||||
val handler =
|
||||
CalendarHandler.forTesting(
|
||||
appContext(),
|
||||
FakeCalendarDataSource(canRead = true, events = listOf(event)),
|
||||
)
|
||||
|
||||
val result = handler.handleCalendarEvents("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val events = payload.getValue("events").jsonArray
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("Sprint Planning", events.first().jsonObject.getValue("title").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCalendarAdd_mapsNotFoundErrorCode() {
|
||||
val source =
|
||||
FakeCalendarDataSource(
|
||||
canRead = true,
|
||||
canWrite = true,
|
||||
addError = IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar"),
|
||||
)
|
||||
val handler = CalendarHandler.forTesting(appContext(), source)
|
||||
|
||||
val result =
|
||||
handler.handleCalendarAdd(
|
||||
"""{"title":"Call","startISO":"2026-02-28T10:00:00Z","endISO":"2026-02-28T11:00:00Z"}""",
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeCalendarDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val canWrite: Boolean = false,
|
||||
private val events: List<CalendarEventRecord> = emptyList(),
|
||||
private val addResult: CalendarEventRecord =
|
||||
CalendarEventRecord(
|
||||
identifier = "0",
|
||||
title = "Default",
|
||||
startISO = "2026-01-01T00:00:00Z",
|
||||
endISO = "2026-01-01T01:00:00Z",
|
||||
isAllDay = false,
|
||||
location = null,
|
||||
calendarTitle = null,
|
||||
),
|
||||
private val addError: Throwable? = null,
|
||||
) : CalendarDataSource {
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean = canWrite
|
||||
|
||||
override fun events(context: Context, request: CalendarEventsRequest): List<CalendarEventRecord> = events
|
||||
|
||||
override fun add(context: Context, request: CalendarAddRequest): CalendarEventRecord {
|
||||
addError?.let { throw it }
|
||||
return addResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CameraHandlerTest {
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_allowsZeroAndLimit() {
|
||||
assertTrue(isCameraClipWithinPayloadLimit(0L))
|
||||
assertTrue(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_rejectsNegativeAndTooLarge() {
|
||||
assertFalse(isCameraClipWithinPayloadLimit(-1L))
|
||||
assertFalse(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES + 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraClipMaxRawBytes_matchesExpectedBudget() {
|
||||
assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContactsHandlerTest {
|
||||
@Test
|
||||
fun handleContactsSearch_requiresReadPermission() {
|
||||
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
|
||||
|
||||
val result = handler.handleContactsSearch(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CONTACTS_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsAdd_rejectsEmptyContact() {
|
||||
val handler =
|
||||
ContactsHandler.forTesting(
|
||||
appContext(),
|
||||
FakeContactsDataSource(canRead = true, canWrite = true),
|
||||
)
|
||||
|
||||
val result = handler.handleContactsAdd("""{"givenName":" ","emails":[]}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CONTACTS_INVALID", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsSearch_returnsContacts() {
|
||||
val contact =
|
||||
ContactRecord(
|
||||
identifier = "1",
|
||||
displayName = "Ada Lovelace",
|
||||
givenName = "Ada",
|
||||
familyName = "Lovelace",
|
||||
organizationName = "Analytical Engine",
|
||||
phoneNumbers = listOf("+12025550123"),
|
||||
emails = listOf("ada@example.com"),
|
||||
)
|
||||
val handler =
|
||||
ContactsHandler.forTesting(
|
||||
appContext(),
|
||||
FakeContactsDataSource(canRead = true, searchResults = listOf(contact)),
|
||||
)
|
||||
|
||||
val result = handler.handleContactsSearch("""{"query":"ada","limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val contacts = payload.getValue("contacts").jsonArray
|
||||
assertEquals(1, contacts.size)
|
||||
assertEquals("Ada Lovelace", contacts.first().jsonObject.getValue("displayName").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleContactsAdd_returnsAddedContact() {
|
||||
val added =
|
||||
ContactRecord(
|
||||
identifier = "2",
|
||||
displayName = "Grace Hopper",
|
||||
givenName = "Grace",
|
||||
familyName = "Hopper",
|
||||
organizationName = "US Navy",
|
||||
phoneNumbers = listOf(),
|
||||
emails = listOf("grace@example.com"),
|
||||
)
|
||||
val source = FakeContactsDataSource(canRead = true, canWrite = true, addResult = added)
|
||||
val handler = ContactsHandler.forTesting(appContext(), source)
|
||||
|
||||
val result =
|
||||
handler.handleContactsAdd(
|
||||
"""{"givenName":"Grace","familyName":"Hopper","emails":["grace@example.com"]}""",
|
||||
)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val contact = payload.getValue("contact").jsonObject
|
||||
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
|
||||
assertEquals(1, source.addCalls)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeContactsDataSource(
|
||||
private val canRead: Boolean,
|
||||
private val canWrite: Boolean = false,
|
||||
private val searchResults: List<ContactRecord> = emptyList(),
|
||||
private val addResult: ContactRecord =
|
||||
ContactRecord(
|
||||
identifier = "0",
|
||||
displayName = "Default",
|
||||
givenName = "",
|
||||
familyName = "",
|
||||
organizationName = "",
|
||||
phoneNumbers = emptyList(),
|
||||
emails = emptyList(),
|
||||
),
|
||||
) : ContactsDataSource {
|
||||
var addCalls: Int = 0
|
||||
private set
|
||||
|
||||
override fun hasReadPermission(context: Context): Boolean = canRead
|
||||
|
||||
override fun hasWritePermission(context: Context): Boolean = canWrite
|
||||
|
||||
override fun search(context: Context, request: ContactsSearchRequest): List<ContactRecord> = searchResults
|
||||
|
||||
override fun add(context: Context, request: ContactsAddRequest): ContactRecord {
|
||||
addCalls += 1
|
||||
return addResult
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,73 @@ class DeviceHandlerTest {
|
||||
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDevicePermissions_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDevicePermissions(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val permissions = payload.getValue("permissions").jsonObject
|
||||
val expected =
|
||||
listOf(
|
||||
"camera",
|
||||
"microphone",
|
||||
"location",
|
||||
"backgroundLocation",
|
||||
"sms",
|
||||
"notificationListener",
|
||||
"notifications",
|
||||
"photos",
|
||||
"contacts",
|
||||
"calendar",
|
||||
"motion",
|
||||
"screenCapture",
|
||||
)
|
||||
for (key in expected) {
|
||||
val state = permissions.getValue(key).jsonObject
|
||||
val status = state.getValue("status").jsonPrimitive.content
|
||||
assertTrue(status == "granted" || status == "denied")
|
||||
state.getValue("promptable").jsonPrimitive.boolean
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceHealth_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceHealth(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val memory = payload.getValue("memory").jsonObject
|
||||
val battery = payload.getValue("battery").jsonObject
|
||||
val power = payload.getValue("power").jsonObject
|
||||
val system = payload.getValue("system").jsonObject
|
||||
|
||||
val pressure = memory.getValue("pressure").jsonPrimitive.content
|
||||
assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown"))
|
||||
val totalRamBytes = memory.getValue("totalRamBytes").jsonPrimitive.content.toLong()
|
||||
val availableRamBytes = memory.getValue("availableRamBytes").jsonPrimitive.content.toLong()
|
||||
val usedRamBytes = memory.getValue("usedRamBytes").jsonPrimitive.content.toLong()
|
||||
assertTrue(totalRamBytes >= 0L)
|
||||
assertTrue(availableRamBytes >= 0L)
|
||||
assertTrue(usedRamBytes >= 0L)
|
||||
memory.getValue("lowMemory").jsonPrimitive.boolean
|
||||
|
||||
val batteryState = battery.getValue("state").jsonPrimitive.content
|
||||
assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full"))
|
||||
val chargingType = battery.getValue("chargingType").jsonPrimitive.content
|
||||
assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock"))
|
||||
battery["temperatureC"]?.jsonPrimitive?.double
|
||||
battery["currentMa"]?.jsonPrimitive?.double
|
||||
|
||||
power.getValue("dozeModeEnabled").jsonPrimitive.boolean
|
||||
power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
|
||||
system["securityPatchLevel"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
|
||||
@@ -1,31 +1,116 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCalendarCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.protocol.OpenClawContactsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCapabilities_respectsFeatureAvailability() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
@@ -36,21 +121,57 @@ class InvokeCommandRegistryTest {
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_onlyIncludesSupportedMotionCommands() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = false))
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_rejectsInvalidJson() =
|
||||
runTest {
|
||||
val handler = MotionHandler.forTesting(appContext(), FakeMotionDataSource(hasPermission = true))
|
||||
|
||||
val result = handler.handleMotionActivity("[]")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionActivity_returnsActivityPayload() =
|
||||
runTest {
|
||||
val activity =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T10:00:00Z",
|
||||
endISO = "2026-02-28T10:00:02Z",
|
||||
confidence = "high",
|
||||
isWalking = true,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = false,
|
||||
isUnknown = false,
|
||||
)
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(hasPermission = true, activityRecord = activity),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionActivity(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val activities = payload.getValue("activities").jsonArray
|
||||
assertEquals(1, activities.size)
|
||||
assertEquals("high", activities.first().jsonObject.getValue("confidence").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleMotionPedometer_mapsRangeUnsupportedError() =
|
||||
runTest {
|
||||
val handler =
|
||||
MotionHandler.forTesting(
|
||||
appContext(),
|
||||
FakeMotionDataSource(
|
||||
hasPermission = true,
|
||||
pedometerError = IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: not supported"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleMotionPedometer("""{"startISO":"2026-02-01T00:00:00Z"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
private val hasPermission: Boolean,
|
||||
private val activityAvailable: Boolean = true,
|
||||
private val pedometerAvailable: Boolean = true,
|
||||
private val activityRecord: MotionActivityRecord =
|
||||
MotionActivityRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T00:00:02Z",
|
||||
confidence = "medium",
|
||||
isWalking = false,
|
||||
isRunning = false,
|
||||
isCycling = false,
|
||||
isAutomotive = false,
|
||||
isStationary = true,
|
||||
isUnknown = false,
|
||||
),
|
||||
private val pedometerRecord: PedometerRecord =
|
||||
PedometerRecord(
|
||||
startISO = "2026-02-28T00:00:00Z",
|
||||
endISO = "2026-02-28T01:00:00Z",
|
||||
steps = 1234,
|
||||
distanceMeters = null,
|
||||
floorsAscended = null,
|
||||
floorsDescended = null,
|
||||
),
|
||||
private val activityError: Throwable? = null,
|
||||
private val pedometerError: Throwable? = null,
|
||||
) : MotionDataSource {
|
||||
override fun isActivityAvailable(context: Context): Boolean = activityAvailable
|
||||
|
||||
override fun isPedometerAvailable(context: Context): Boolean = pedometerAvailable
|
||||
|
||||
override fun hasPermission(context: Context): Boolean = hasPermission
|
||||
|
||||
override suspend fun activity(context: Context, request: MotionActivityRequest): MotionActivityRecord {
|
||||
activityError?.let { throw it }
|
||||
return activityRecord
|
||||
}
|
||||
|
||||
override suspend fun pedometer(context: Context, request: MotionPedometerRequest): PedometerRecord {
|
||||
pedometerError?.let { throw it }
|
||||
return pedometerRecord
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,98 @@ class NotificationsHandlerTest {
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_executesDismissAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("ok").jsonPrimitive.boolean)
|
||||
assertEquals("n2", payload.getValue("key").jsonPrimitive.content)
|
||||
assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content)
|
||||
assertEquals("n2", provider.lastAction?.key)
|
||||
assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requiresReplyTextForReplyAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n3")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n3","action":"reply"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
assertEquals(0, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_propagatesProviderError() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n4")),
|
||||
),
|
||||
).also {
|
||||
it.actionResult =
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
}
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n5")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n5","action":"open"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
@@ -110,6 +202,13 @@ class NotificationsHandlerTest {
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActionClearablePolicy_onlyRequiresClearableForDismiss() {
|
||||
assertTrue(actionRequiresClearableNotification(NotificationActionKind.Dismiss))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Open))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Reply))
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
@@ -137,10 +236,23 @@ private class FakeNotificationsStateProvider(
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
var actionRequests: Int = 0
|
||||
private set
|
||||
var actionResult: NotificationActionResult = NotificationActionResult(ok = true)
|
||||
var lastAction: NotificationActionRequest? = null
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
|
||||
override fun executeAction(
|
||||
context: Context,
|
||||
request: NotificationActionRequest,
|
||||
): NotificationActionResult {
|
||||
actionRequests += 1
|
||||
lastAction = request
|
||||
return actionResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PhotosHandlerTest {
|
||||
@Test
|
||||
fun handlePhotosLatest_requiresPermission() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
|
||||
|
||||
val result = handler.handlePhotosLatest(null)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("PHOTOS_PERMISSION_REQUIRED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePhotosLatest_rejectsInvalidJson() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = true))
|
||||
|
||||
val result = handler.handlePhotosLatest("[]")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handlePhotosLatest_returnsPayload() {
|
||||
val source =
|
||||
FakePhotosDataSource(
|
||||
hasPermission = true,
|
||||
latest = listOf(
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = "abc123",
|
||||
width = 640,
|
||||
height = 480,
|
||||
createdAt = "2026-02-28T00:00:00Z",
|
||||
),
|
||||
),
|
||||
)
|
||||
val handler = PhotosHandler.forTesting(appContext(), source)
|
||||
|
||||
val result = handler.handlePhotosLatest("""{"limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
|
||||
val photos = payload.getValue("photos").jsonArray
|
||||
assertEquals(1, photos.size)
|
||||
val first = photos.first().jsonObject
|
||||
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
|
||||
assertEquals(640, first.getValue("width").jsonPrimitive.int)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakePhotosDataSource(
|
||||
private val hasPermission: Boolean,
|
||||
private val latest: List<EncodedPhotoPayload> = emptyList(),
|
||||
) : PhotosDataSource {
|
||||
override fun hasPermission(context: Context): Boolean = hasPermission
|
||||
|
||||
override fun latest(context: Context, request: PhotosLatestRequest): List<EncodedPhotoPayload> = latest
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SystemHandlerTest {
|
||||
@Test
|
||||
fun handleSystemNotify_rejectsUnauthorized() {
|
||||
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = false))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"hi"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOT_AUTHORIZED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_rejectsEmptyNotification() {
|
||||
val handler = SystemHandler.forTesting(poster = FakePoster(authorized = true))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":" ","body":" "}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_postsNotification() {
|
||||
val poster = FakePoster(authorized = true)
|
||||
val handler = SystemHandler.forTesting(poster = poster)
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done","priority":"active"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, poster.posts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_returnsUnauthorizedWhenPostFailsPermission() {
|
||||
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = SecurityException("denied")))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOT_AUTHORIZED", result.error?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSystemNotify_returnsUnavailableWhenPostFailsUnexpectedly() {
|
||||
val handler = SystemHandler.forTesting(poster = ThrowingPoster(authorized = true, error = IllegalStateException("boom")))
|
||||
|
||||
val result = handler.handleSystemNotify("""{"title":"OpenClaw","body":"done"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("UNAVAILABLE", result.error?.code)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakePoster(
|
||||
private val authorized: Boolean,
|
||||
) : SystemNotificationPoster {
|
||||
var posts: Int = 0
|
||||
private set
|
||||
|
||||
override fun isAuthorized(): Boolean = authorized
|
||||
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
posts += 1
|
||||
}
|
||||
}
|
||||
|
||||
private class ThrowingPoster(
|
||||
private val authorized: Boolean,
|
||||
private val error: Throwable,
|
||||
) : SystemNotificationPoster {
|
||||
override fun isAuthorized(): Boolean = authorized
|
||||
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("location", OpenClawCapability.Location.rawValue)
|
||||
assertEquals("sms", OpenClawCapability.Sms.rawValue)
|
||||
assertEquals("device", OpenClawCapability.Device.rawValue)
|
||||
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
|
||||
assertEquals("system", OpenClawCapability.System.rawValue)
|
||||
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
|
||||
assertEquals("photos", OpenClawCapability.Photos.rawValue)
|
||||
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
|
||||
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
|
||||
assertEquals("motion", OpenClawCapability.Motion.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraCommandsUseStableStrings() {
|
||||
assertEquals("camera.list", OpenClawCameraCommand.List.rawValue)
|
||||
assertEquals("camera.snap", OpenClawCameraCommand.Snap.rawValue)
|
||||
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -39,11 +53,42 @@ class OpenClawProtocolConstantsTest {
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
assertEquals("notifications.actions", OpenClawNotificationsCommand.Actions.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceCommandsUseStableStrings() {
|
||||
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
|
||||
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemCommandsUseStableStrings() {
|
||||
assertEquals("system.notify", OpenClawSystemCommand.Notify.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun photosCommandsUseStableStrings() {
|
||||
assertEquals("photos.latest", OpenClawPhotosCommand.Latest.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactsCommandsUseStableStrings() {
|
||||
assertEquals("contacts.search", OpenClawContactsCommand.Search.rawValue)
|
||||
assertEquals("contacts.add", OpenClawContactsCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun calendarCommandsUseStableStrings() {
|
||||
assertEquals("calendar.events", OpenClawCalendarCommand.Events.rawValue)
|
||||
assertEquals("calendar.add", OpenClawCalendarCommand.Add.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun motionCommandsUseStableStrings() {
|
||||
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
|
||||
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
id("com.android.test")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.android.benchmark"
|
||||
compileSdk = 36
|
||||
namespace = "ai.openclaw.android.benchmark"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
|
||||
}
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
|
||||
}
|
||||
|
||||
targetProjectPath = ":app"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
targetProjectPath = ":app"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
exclude("**/build/**")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("com.android.test") version "9.0.1" apply false
|
||||
id("org.jlleitschuh.gradle.ktlint") version "14.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
|
||||
}
|
||||
|
||||
@@ -3,12 +3,7 @@ org.gradle.warning.mode=none
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.enableR8.fullMode=true
|
||||
android.defaults.buildfeatures.resvalues=true
|
||||
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
|
||||
android.enableAppCompileTimeRClass=false
|
||||
android.usesSdkInManifest.disallowed=false
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.dependency.useConstraints=false
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.r8.optimizedResourceShrinking=false
|
||||
android.newDsl=true
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260301</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -54,7 +54,12 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)"
|
||||
)
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
|
||||
@@ -212,7 +212,7 @@ final class GatewayConnectionController {
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ final class GatewayConnectionController {
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(target)
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -411,7 +411,7 @@ final class GatewayConnectionController {
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -632,7 +632,8 @@ final class GatewayConnectionController {
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
@@ -889,11 +890,9 @@ final class GatewayConnectionController {
|
||||
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
||||
|
||||
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
permissions["calendar"] =
|
||||
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
|
||||
permissions["calendar"] = Self.hasEventKitAccess(calendarStatus)
|
||||
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
permissions["reminders"] =
|
||||
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
|
||||
permissions["reminders"] = Self.hasEventKitAccess(remindersStatus)
|
||||
|
||||
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
||||
let pedometerStatus = CMPedometer.authorizationStatus()
|
||||
@@ -911,13 +910,17 @@ final class GatewayConnectionController {
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasEventKitAccess(_ status: EKAuthorizationStatus) -> Bool {
|
||||
status == .fullAccess || status == .writeOnly
|
||||
}
|
||||
|
||||
private static func motionAvailable() -> Bool {
|
||||
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
||||
}
|
||||
@@ -986,7 +989,7 @@ extension GatewayConnectionController {
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260301</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -46,6 +46,7 @@ private enum IOSDeepLinkAgentPolicy {
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
// swiftlint:disable type_body_length file_length
|
||||
final class NodeAppModel {
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
@@ -414,8 +415,10 @@ final class NodeAppModel {
|
||||
}
|
||||
let wasSuppressed = self.backgroundReconnectSuppressed
|
||||
self.backgroundReconnectSuppressed = false
|
||||
self.pushWakeLogger.info(
|
||||
"Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)")
|
||||
let leaseLogMessage =
|
||||
"Background reconnect lease reason=\(reason) "
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
||||
}
|
||||
|
||||
private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) {
|
||||
@@ -425,8 +428,10 @@ final class NodeAppModel {
|
||||
self.backgroundReconnectLeaseUntil = nil
|
||||
self.backgroundReconnectSuppressed = true
|
||||
guard changed else { return }
|
||||
self.pushWakeLogger.info(
|
||||
"Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)")
|
||||
let suppressLogMessage =
|
||||
"Background reconnect suppressed reason=\(reason) "
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
||||
guard disconnectIfNeeded else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -607,7 +612,7 @@ final class NodeAppModel {
|
||||
self.voiceWakeSyncTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if !(await self.isGatewayHealthMonitorDisabled()) {
|
||||
if !self.isGatewayHealthMonitorDisabled() {
|
||||
await self.refreshWakeWordsFromGateway()
|
||||
}
|
||||
|
||||
@@ -662,9 +667,13 @@ final class NodeAppModel {
|
||||
self.gatewayHealthMonitor.start(
|
||||
check: { [weak self] in
|
||||
guard let self else { return false }
|
||||
if await self.isGatewayHealthMonitorDisabled() { return true }
|
||||
if await MainActor.run(body: { self.isGatewayHealthMonitorDisabled() }) { return true }
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 6
|
||||
)
|
||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
@@ -1765,7 +1774,10 @@ private extension NodeAppModel {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue }
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1830,6 +1842,8 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
||||
// swiftlint:disable:next function_body_length
|
||||
func startNodeGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
@@ -1854,7 +1868,10 @@ private extension NodeAppModel {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue }
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "node_loop") {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isGatewayConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1898,7 +1915,10 @@ private extension NodeAppModel {
|
||||
sessionKey: relayData.sessionKey,
|
||||
deliveryChannel: relayData.deliveryChannel,
|
||||
deliveryTo: relayData.deliveryTo))
|
||||
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") "
|
||||
+ "scheme=\(url.scheme ?? "?")"
|
||||
)
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
@@ -1993,9 +2013,11 @@ private extension NodeAppModel {
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway and return to OpenClaw."
|
||||
"Pairing required (requestId: \(requestId)). "
|
||||
+ "Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway and return to OpenClaw."
|
||||
self.gatewayStatusText =
|
||||
"Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
@@ -2213,12 +2235,16 @@ extension NodeAppModel {
|
||||
key: event.replyId)
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)")
|
||||
let forwardedMessage =
|
||||
"watch reply forwarded replyId=\(event.replyId) "
|
||||
+ "action=\(event.actionId)"
|
||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
self.watchReplyLogger.error(
|
||||
"watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
let failedMessage =
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
}
|
||||
}
|
||||
@@ -2252,21 +2278,37 @@ extension NodeAppModel {
|
||||
return false
|
||||
}
|
||||
let pushKind = Self.openclawPushKind(userInfo)
|
||||
self.pushWakeLogger.info(
|
||||
"Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let receivedMessage =
|
||||
"Silent push received wakeId=\(wakeId) "
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
self.pushWakeLogger.info(
|
||||
"Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
||||
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
|
||||
let wakeId = Self.makePushWakeAttemptID()
|
||||
self.pushWakeLogger.info(
|
||||
"Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let receivedMessage =
|
||||
"Background refresh wake received wakeId=\(wakeId) "
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
self.pushWakeLogger.info(
|
||||
"Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
let outcomeMessage =
|
||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
||||
@@ -2283,17 +2325,26 @@ extension NodeAppModel {
|
||||
if let last = self.lastSignificantLocationWakeAt,
|
||||
now.timeIntervalSince(last) < throttleWindowSeconds
|
||||
{
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)")
|
||||
let throttledMessage =
|
||||
"Location wake throttled wakeId=\(wakeId) "
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.lastSignificantLocationWakeAt = now
|
||||
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let beginMessage =
|
||||
"Location wake begin wakeId=\(wakeId) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
let triggerMessage =
|
||||
"Location wake trigger wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||
|
||||
guard result.applied else { return }
|
||||
let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250)
|
||||
@@ -2451,14 +2502,18 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "voicewake.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
||||
await self.setGatewayHealthMonitorDisabled(true)
|
||||
self.setGatewayHealthMonitorDisabled(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2513,7 +2568,8 @@ extension NodeAppModel {
|
||||
)
|
||||
|
||||
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
||||
self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
||||
self.screen.errorText = "Deep link too large (message exceeds "
|
||||
+ "\(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
||||
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
||||
return
|
||||
}
|
||||
@@ -2728,3 +2784,4 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -20,7 +20,7 @@ final class MotionService: MotionServicing {
|
||||
let limit = max(1, min(params.limit ?? 200, 1000))
|
||||
|
||||
let manager = CMMotionActivityManager()
|
||||
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
|
||||
let mapped: [OpenClawMotionActivityEntry] = try await withCheckedThrowingContinuation { cont in
|
||||
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
@@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let pedometer = CMPedometer()
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
|
||||
let payload: OpenClawPedometerPayload = try await withCheckedThrowingContinuation { cont in
|
||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
|
||||
@@ -134,7 +134,10 @@ struct OnboardingWizardView: View {
|
||||
Button("Done") {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil)
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,8 +719,10 @@ struct OnboardingWizardView: View {
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode, context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
ofType: CIDetectorTypeQRCode,
|
||||
context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
||||
)
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
|
||||
@@ -4,7 +4,7 @@ import OpenClawKit
|
||||
import os
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
import UserNotifications
|
||||
@preconcurrency import UserNotifications
|
||||
|
||||
private struct PendingWatchPromptAction {
|
||||
var promptId: String?
|
||||
@@ -119,11 +119,19 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
request.earliestBeginDate = Date().addingTimeInterval(max(60, delay))
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
let scheduledLogMessage =
|
||||
"Scheduled background wake refresh reason=\(reason) "
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
self.backgroundWakeLogger.info(
|
||||
"Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)")
|
||||
"\(scheduledLogMessage, privacy: .public)"
|
||||
)
|
||||
} catch {
|
||||
let failedLogMessage =
|
||||
"Failed scheduling background wake refresh reason=\(reason) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.backgroundWakeLogger.error(
|
||||
"Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
"\(failedLogMessage, privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +426,9 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus {
|
||||
private static func notificationAuthorizationStatus(
|
||||
center: UNUserNotificationCenter
|
||||
) async -> UNAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
@@ -440,7 +450,10 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws {
|
||||
private static func addNotificationRequest(
|
||||
_ request: UNNotificationRequest,
|
||||
center: UNUserNotificationCenter
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
if let error {
|
||||
|
||||
@@ -17,7 +17,7 @@ final class RemindersService: RemindersServicing {
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
let predicate = store.predicateForReminders(in: nil)
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
|
||||
let payload: [OpenClawReminderPayload] = try await withCheckedThrowingContinuation { cont in
|
||||
store.fetchReminders(matching: predicate) { items in
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let filtered = (items ?? []).filter { reminder in
|
||||
|
||||
@@ -3,10 +3,13 @@ import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
typealias OpenClawCameraSnapResult = (format: String, base64: String, width: Int, height: Int)
|
||||
typealias OpenClawCameraClipResult = (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
|
||||
protocol CameraServicing: Sendable {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult
|
||||
}
|
||||
|
||||
protocol ScreenRecordingServicing: Sendable {
|
||||
|
||||
@@ -148,11 +148,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
|
||||
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume()
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { _ in
|
||||
continuation.resume()
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
struct SettingsTab: View {
|
||||
private struct FeatureHelp: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -228,7 +229,10 @@ struct SettingsTab: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -275,7 +279,9 @@ struct SettingsTab: View {
|
||||
self.featureToggle(
|
||||
"Allow Camera",
|
||||
isOn: self.$cameraEnabled,
|
||||
help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
|
||||
help: "Allows the gateway to request photos or short video clips "
|
||||
+ "while OpenClaw is foregrounded."
|
||||
)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Location Access")
|
||||
@@ -283,7 +289,11 @@ struct SettingsTab: View {
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Location Access",
|
||||
message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
|
||||
message: "Controls location permissions for OpenClaw. "
|
||||
+ "Off disables location tools, While Using enables "
|
||||
+ "foreground location, and Always enables "
|
||||
+ "background location."
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -313,7 +323,11 @@ struct SettingsTab: View {
|
||||
LabeledContent(
|
||||
"API Key",
|
||||
value: self.appModel.talkMode.gatewayTalkConfigLoaded
|
||||
? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured")
|
||||
? (
|
||||
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
||||
? "Configured"
|
||||
: "Not configured"
|
||||
)
|
||||
: "Not loaded")
|
||||
LabeledContent(
|
||||
"Default Model",
|
||||
@@ -340,7 +354,9 @@ struct SettingsTab: View {
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Default Share Instruction",
|
||||
message: "Appends this instruction when sharing content into OpenClaw from iOS.")
|
||||
message: "Appends this instruction when sharing content "
|
||||
+ "into OpenClaw from iOS."
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -393,7 +409,9 @@ struct SettingsTab: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
||||
"This will disconnect, clear saved gateway connection + credentials, "
|
||||
+ "and reopen the onboarding wizard."
|
||||
)
|
||||
}
|
||||
.alert(item: self.$activeFeatureHelp) { help in
|
||||
Alert(
|
||||
@@ -701,7 +719,9 @@ struct SettingsTab: View {
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
GatewayDiagnostics.log(
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) "
|
||||
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)"
|
||||
)
|
||||
guard let port = resolvedPort else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
@@ -1009,3 +1029,4 @@ struct SettingsTab: View {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -51,7 +51,11 @@ struct StatusPill: View {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
|
||||
@@ -10,7 +10,7 @@ import Speech
|
||||
// This file intentionally centralizes talk mode state + behavior.
|
||||
// It's large, and splitting would force `private` -> `fileprivate` across many members.
|
||||
// We'll refactor into smaller files when the surface stabilizes.
|
||||
// swiftlint:disable type_body_length
|
||||
// swiftlint:disable type_body_length file_length
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TalkModeManager: NSObject {
|
||||
@@ -156,9 +156,7 @@ final class TalkModeManager: NSObject {
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.logger.warning("start blocked: microphone permission denied")
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
self.statusText = "Microphone permission denied"
|
||||
return
|
||||
}
|
||||
let speechOk = await Self.requestSpeechPermission()
|
||||
@@ -300,9 +298,7 @@ final class TalkModeManager: NSObject {
|
||||
if !self.allowSimulatorCapture {
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
self.statusText = "Microphone permission denied"
|
||||
throw NSError(domain: "TalkMode", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Microphone permission denied",
|
||||
])
|
||||
@@ -470,14 +466,15 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
} else {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -525,7 +522,9 @@ final class TalkModeManager: NSObject {
|
||||
self.noiseFloorSamples.removeAll(keepingCapacity: true)
|
||||
let threshold = min(0.35, max(0.12, avg + 0.10))
|
||||
GatewayDiagnostics.log(
|
||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) threshold=\(String(format: "%.3f", threshold))")
|
||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
|
||||
+ "threshold=\(String(format: "%.3f", threshold))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +548,9 @@ final class TalkModeManager: NSObject {
|
||||
self.loggedPartialThisCycle = false
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) engineRunning=\(self.audioEngine.isRunning)")
|
||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) "
|
||||
+ "engineRunning=\(self.audioEngine.isRunning)"
|
||||
)
|
||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
@@ -1316,11 +1317,11 @@ final class TalkModeManager: NSObject {
|
||||
try Task.checkCancellation()
|
||||
chunks.append(chunk)
|
||||
}
|
||||
await self?.completeIncrementalPrefetch(id: id, chunks: chunks)
|
||||
self?.completeIncrementalPrefetch(id: id, chunks: chunks)
|
||||
} catch is CancellationError {
|
||||
await self?.clearIncrementalPrefetch(id: id)
|
||||
self?.clearIncrementalPrefetch(id: id)
|
||||
} catch {
|
||||
await self?.failIncrementalPrefetch(id: id, error: error)
|
||||
self?.failIncrementalPrefetch(id: id, error: error)
|
||||
}
|
||||
}
|
||||
self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState(
|
||||
@@ -1426,7 +1427,10 @@ final class TalkModeManager: NSObject {
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "agent", let payload = evt.payload else { continue }
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(payload, as: OpenClawAgentEventPayload.self) else {
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawAgentEventPayload.self
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
||||
@@ -1726,23 +1730,20 @@ private struct IncrementalSpeechBuffer {
|
||||
|
||||
extension TalkModeManager {
|
||||
nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
break
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioApplication.requestRecordPermission(completionHandler: { ok in
|
||||
completion(ok)
|
||||
})
|
||||
}
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
||||
completion(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
@@ -1766,7 +1767,7 @@ extension TalkModeManager {
|
||||
}
|
||||
|
||||
private nonisolated static func requestPermissionWithTimeout(
|
||||
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
||||
_ operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
@@ -1910,7 +1911,7 @@ extension TalkModeManager {
|
||||
}
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.sorted().first ??
|
||||
normalizedProviders.keys.min() ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
@@ -1920,7 +1921,11 @@ extension TalkModeManager {
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8)
|
||||
let res = try await gateway.request(
|
||||
method: "talk.config",
|
||||
paramsJSON: "{\"includeSecrets\":true}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
@@ -2007,10 +2012,18 @@ extension TalkModeManager {
|
||||
|
||||
private static func describeAudioSession() -> String {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
|
||||
let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
|
||||
let available = session.availableInputs?.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") ?? ""
|
||||
return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]"
|
||||
let inputs = session.currentRoute.inputs
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",")
|
||||
let outputs = session.currentRoute.outputs
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",")
|
||||
let available = session.availableInputs?
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",") ?? ""
|
||||
return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) "
|
||||
+ "opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) "
|
||||
+ "routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2078,7 +2091,9 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
|
||||
guard shouldLog else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2135,4 +2150,4 @@ private struct IncrementalPrefetchedAudio {
|
||||
let outputFormat: String?
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260301</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260301</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260226</string>
|
||||
<string>20260301</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.3.1"
|
||||
CFBundleVersion: "20260301"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -133,11 +133,13 @@ targets:
|
||||
- path: ShareExtension
|
||||
dependencies:
|
||||
- package: OpenClawKit
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
SWIFT_VERSION: "6.0"
|
||||
@@ -146,8 +148,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.3.1"
|
||||
CFBundleVersion: "20260301"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -171,13 +173,14 @@ targets:
|
||||
Release: Config/Signing.xcconfig
|
||||
settings:
|
||||
base:
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
info:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.3.1"
|
||||
CFBundleVersion: "20260301"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -200,8 +203,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.3.1"
|
||||
CFBundleVersion: "20260301"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -234,5 +237,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
CFBundleShortVersionString: "2026.3.1"
|
||||
CFBundleVersion: "20260301"
|
||||
|
||||
@@ -226,6 +226,7 @@ enum ExecApprovalsStore {
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
private static let defaultAutoAllowSkills = false
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
|
||||
static func fileURL() -> URL {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||
@@ -332,6 +333,7 @@ enum ExecApprovalsStore {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let url = self.fileURL()
|
||||
self.ensureSecureStateDirectory()
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
@@ -343,6 +345,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
self.ensureSecureStateDirectory()
|
||||
let url = self.fileURL()
|
||||
let existed = FileManager().fileExists(atPath: url.path)
|
||||
let loaded = self.loadFile()
|
||||
@@ -524,6 +527,20 @@ enum ExecApprovalsStore {
|
||||
self.saveFile(file)
|
||||
}
|
||||
|
||||
private static func ensureSecureStateDirectory() {
|
||||
let url = OpenClawPaths.stateDirURL
|
||||
do {
|
||||
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger
|
||||
.warning(
|
||||
"exec approvals state dir permission hardening failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func generateToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
|
||||
@@ -355,9 +355,9 @@ private enum ExecHostExecutor {
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let validatedRequest: ExecHostValidatedRequest
|
||||
switch ExecHostRequestEvaluator.validateRequest(request) {
|
||||
case .success(let request):
|
||||
case let .success(request):
|
||||
validatedRequest = request
|
||||
case .failure(let error):
|
||||
case let .failure(error):
|
||||
return self.errorResponse(error)
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ private enum ExecHostExecutor {
|
||||
context: context,
|
||||
approvalDecision: request.approvalDecision)
|
||||
{
|
||||
case .deny(let error):
|
||||
case let .deny(error):
|
||||
return self.errorResponse(error)
|
||||
case .allow:
|
||||
break
|
||||
@@ -401,7 +401,7 @@ private enum ExecHostExecutor {
|
||||
context: context,
|
||||
approvalDecision: followupDecision)
|
||||
{
|
||||
case .deny(let error):
|
||||
case let .deny(error):
|
||||
return self.errorResponse(error)
|
||||
case .allow:
|
||||
break
|
||||
@@ -544,6 +544,106 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathKind: Equatable {
|
||||
case missing
|
||||
case directory
|
||||
case socket
|
||||
case symlink
|
||||
case other
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathGuardError: LocalizedError {
|
||||
case lstatFailed(path: String, code: Int32)
|
||||
case parentPathInvalid(path: String, kind: ExecApprovalsSocketPathKind)
|
||||
case socketPathInvalid(path: String, kind: ExecApprovalsSocketPathKind)
|
||||
case unlinkFailed(path: String, code: Int32)
|
||||
case createParentDirectoryFailed(path: String, message: String)
|
||||
case setParentDirectoryPermissionsFailed(path: String, message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .lstatFailed(path, code):
|
||||
"lstat failed for \(path) (errno \(code))"
|
||||
case let .parentPathInvalid(path, kind):
|
||||
"socket parent path invalid (\(kind)) at \(path)"
|
||||
case let .socketPathInvalid(path, kind):
|
||||
"socket path invalid (\(kind)) at \(path)"
|
||||
case let .unlinkFailed(path, code):
|
||||
"unlink failed for \(path) (errno \(code))"
|
||||
case let .createParentDirectoryFailed(path, message):
|
||||
"socket parent directory create failed at \(path): \(message)"
|
||||
case let .setParentDirectoryPermissionsFailed(path, message):
|
||||
"socket parent directory chmod failed at \(path): \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathGuard {
|
||||
static let parentDirectoryPermissions = 0o700
|
||||
|
||||
static func pathKind(at path: String) throws -> ExecApprovalsSocketPathKind {
|
||||
var status = stat()
|
||||
let result = lstat(path, &status)
|
||||
if result != 0 {
|
||||
if errno == ENOENT {
|
||||
return .missing
|
||||
}
|
||||
throw ExecApprovalsSocketPathGuardError.lstatFailed(path: path, code: errno)
|
||||
}
|
||||
|
||||
let fileType = status.st_mode & mode_t(S_IFMT)
|
||||
if fileType == mode_t(S_IFDIR) { return .directory }
|
||||
if fileType == mode_t(S_IFSOCK) { return .socket }
|
||||
if fileType == mode_t(S_IFLNK) { return .symlink }
|
||||
return .other
|
||||
}
|
||||
|
||||
static func hardenParentDirectory(for socketPath: String) throws {
|
||||
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
let parentPath = parentURL.path
|
||||
|
||||
switch try self.pathKind(at: parentPath) {
|
||||
case .missing, .directory:
|
||||
break
|
||||
case let kind:
|
||||
throw ExecApprovalsSocketPathGuardError.parentPathInvalid(path: parentPath, kind: kind)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager().createDirectory(at: parentURL, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw ExecApprovalsSocketPathGuardError.createParentDirectoryFailed(
|
||||
path: parentPath,
|
||||
message: error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.parentDirectoryPermissions],
|
||||
ofItemAtPath: parentPath)
|
||||
} catch {
|
||||
throw ExecApprovalsSocketPathGuardError.setParentDirectoryPermissionsFailed(
|
||||
path: parentPath,
|
||||
message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func removeExistingSocket(at socketPath: String) throws {
|
||||
let kind = try self.pathKind(at: socketPath)
|
||||
switch kind {
|
||||
case .missing:
|
||||
return
|
||||
case .socket:
|
||||
break
|
||||
case .directory, .symlink, .other:
|
||||
throw ExecApprovalsSocketPathGuardError.socketPathInvalid(path: socketPath, kind: kind)
|
||||
}
|
||||
if unlink(socketPath) != 0, errno != ENOENT {
|
||||
throw ExecApprovalsSocketPathGuardError.unlinkFailed(path: socketPath, code: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket")
|
||||
private let socketPath: String
|
||||
@@ -583,7 +683,12 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
self.socketFD = -1
|
||||
}
|
||||
if !self.socketPath.isEmpty {
|
||||
unlink(self.socketPath)
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
} catch {
|
||||
self.logger
|
||||
.warning("exec approvals socket cleanup failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +723,15 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
self.logger.error("exec approvals socket create failed")
|
||||
return -1
|
||||
}
|
||||
unlink(self.socketPath)
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.hardenParentDirectory(for: self.socketPath)
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
} catch {
|
||||
self.logger
|
||||
.error("exec approvals socket path hardening failed: \(error.localizedDescription, privacy: .public)")
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
@@ -645,12 +758,18 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
if chmod(self.socketPath, 0o600) != 0 {
|
||||
self.logger.error("exec approvals socket chmod failed")
|
||||
close(fd)
|
||||
try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
return -1
|
||||
}
|
||||
if listen(fd, 16) != 0 {
|
||||
self.logger.error("exec approvals socket listen failed")
|
||||
close(fd)
|
||||
try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
return -1
|
||||
}
|
||||
chmod(self.socketPath, 0o600)
|
||||
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||
return fd
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ enum ExecHostRequestEvaluator {
|
||||
command: command,
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case .ok(let resolved):
|
||||
case let .ok(resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
case .invalid(let message):
|
||||
case let .invalid(message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
code: "INVALID_REQUEST",
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.26</string>
|
||||
<string>2026.3.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602260</string>
|
||||
<string>202603010</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -534,6 +534,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
@@ -561,6 +562,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
label: String?,
|
||||
@@ -587,6 +589,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
@@ -615,6 +618,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
@@ -2383,6 +2387,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: AnyCodable?
|
||||
public let failurealert: AnyCodable?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -2400,6 +2405,7 @@ public struct CronJob: Codable, Sendable {
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: AnyCodable?,
|
||||
failurealert: AnyCodable?,
|
||||
state: [String: AnyCodable])
|
||||
{
|
||||
self.id = id
|
||||
@@ -2416,6 +2422,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.delivery = delivery
|
||||
self.failurealert = failurealert
|
||||
self.state = state
|
||||
}
|
||||
|
||||
@@ -2434,6 +2441,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case delivery
|
||||
case failurealert = "failureAlert"
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -2490,6 +2498,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: AnyCodable?
|
||||
public let failurealert: AnyCodable?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -2502,7 +2511,8 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: AnyCodable?)
|
||||
delivery: AnyCodable?,
|
||||
failurealert: AnyCodable?)
|
||||
{
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -2515,6 +2525,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.delivery = delivery
|
||||
self.failurealert = failurealert
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2529,6 +2540,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case delivery
|
||||
case failurealert = "failureAlert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2810,6 +2822,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplan: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
@@ -2830,6 +2843,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplan: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
@@ -2849,6 +2863,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplan = systemrunplan
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
@@ -2870,6 +2885,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplan = "systemRunPlan"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ExecApprovalsSocketPathGuardTests {
|
||||
@Test
|
||||
func hardenParentDirectoryCreatesDirectoryWith0700Permissions() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
let socketPath = root
|
||||
.appendingPathComponent("nested", isDirectory: true)
|
||||
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
|
||||
.path
|
||||
|
||||
try ExecApprovalsSocketPathGuard.hardenParentDirectory(for: socketPath)
|
||||
|
||||
let parent = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
#expect(FileManager().fileExists(atPath: parent.path))
|
||||
let attrs = try FileManager().attributesOfItem(atPath: parent.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExistingSocketRejectsSymlinkPath() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let target = root.appendingPathComponent("target.txt")
|
||||
_ = FileManager().createFile(atPath: target.path, contents: Data("x".utf8))
|
||||
let symlink = root.appendingPathComponent("exec-approvals.sock")
|
||||
try FileManager().createSymbolicLink(at: symlink, withDestinationURL: target)
|
||||
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: symlink.path)
|
||||
Issue.record("Expected symlink socket path rejection")
|
||||
} catch let error as ExecApprovalsSocketPathGuardError {
|
||||
switch error {
|
||||
case let .socketPathInvalid(path, kind):
|
||||
#expect(path == symlink.path)
|
||||
#expect(kind == .symlink)
|
||||
default:
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExistingSocketRejectsRegularFilePath() throws {
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-socket-guard-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
let regularFile = root.appendingPathComponent("exec-approvals.sock")
|
||||
_ = FileManager().createFile(atPath: regularFile.path, contents: Data("x".utf8))
|
||||
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: regularFile.path)
|
||||
Issue.record("Expected non-socket path rejection")
|
||||
} catch let error as ExecApprovalsSocketPathGuardError {
|
||||
switch error {
|
||||
case let .socketPathInvalid(path, kind):
|
||||
#expect(path == regularFile.path)
|
||||
#expect(kind == .other)
|
||||
default:
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,22 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func ensureFileHardensStateDirectoryPermissions() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
|
||||
|
||||
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
|
||||
@@ -105,7 +105,9 @@ enum ChatMarkdownPreprocessor {
|
||||
outputLines.append(currentLine)
|
||||
}
|
||||
|
||||
return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression)
|
||||
return outputLines
|
||||
.joined(separator: "\n")
|
||||
.replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
private static func stripPrefixedTimestamps(_ raw: String) -> String {
|
||||
|
||||
@@ -534,6 +534,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
@@ -561,6 +562,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
label: String?,
|
||||
@@ -587,6 +589,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
@@ -615,6 +618,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
@@ -2383,6 +2387,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: AnyCodable?
|
||||
public let failurealert: AnyCodable?
|
||||
public let state: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
@@ -2400,6 +2405,7 @@ public struct CronJob: Codable, Sendable {
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: AnyCodable?,
|
||||
failurealert: AnyCodable?,
|
||||
state: [String: AnyCodable])
|
||||
{
|
||||
self.id = id
|
||||
@@ -2416,6 +2422,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.delivery = delivery
|
||||
self.failurealert = failurealert
|
||||
self.state = state
|
||||
}
|
||||
|
||||
@@ -2434,6 +2441,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case delivery
|
||||
case failurealert = "failureAlert"
|
||||
case state
|
||||
}
|
||||
}
|
||||
@@ -2490,6 +2498,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let wakemode: AnyCodable
|
||||
public let payload: AnyCodable
|
||||
public let delivery: AnyCodable?
|
||||
public let failurealert: AnyCodable?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -2502,7 +2511,8 @@ public struct CronAddParams: Codable, Sendable {
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
payload: AnyCodable,
|
||||
delivery: AnyCodable?)
|
||||
delivery: AnyCodable?,
|
||||
failurealert: AnyCodable?)
|
||||
{
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
@@ -2515,6 +2525,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.wakemode = wakemode
|
||||
self.payload = payload
|
||||
self.delivery = delivery
|
||||
self.failurealert = failurealert
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2529,6 +2540,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case wakemode = "wakeMode"
|
||||
case payload
|
||||
case delivery
|
||||
case failurealert = "failureAlert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2810,6 +2822,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplan: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
@@ -2830,6 +2843,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplan: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
@@ -2849,6 +2863,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplan = systemrunplan
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
@@ -2870,6 +2885,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplan = "systemRunPlan"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
|
||||
1
changelog/fragments/pr-5080.md
Normal file
1
changelog/fragments/pr-5080.md
Normal file
@@ -0,0 +1 @@
|
||||
- Clarify block reply pipeline seen-check parameter naming for maintainability (#5080) (thanks @yassine20011)
|
||||
1
changelog/fragments/pr-5343.md
Normal file
1
changelog/fragments/pr-5343.md
Normal file
@@ -0,0 +1 @@
|
||||
- Memory flush: fix usage-threshold gating and transcript fallback paths so flushes run reliably when expected (#5343) (thanks @jarvis-medmatic)
|
||||
@@ -5,9 +5,9 @@ services:
|
||||
HOME: /home/node
|
||||
TERM: xterm-256color
|
||||
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
@@ -29,14 +29,20 @@ services:
|
||||
|
||||
openclaw-cli:
|
||||
image: ${OPENCLAW_IMAGE:-openclaw:local}
|
||||
network_mode: "service:openclaw-gateway"
|
||||
cap_drop:
|
||||
- NET_RAW
|
||||
- NET_ADMIN
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
HOME: /home/node
|
||||
TERM: xterm-256color
|
||||
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
|
||||
BROWSER: echo
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
@@ -44,3 +50,5 @@ services:
|
||||
tty: true
|
||||
init: true
|
||||
entrypoint: ["node", "dist/index.js"]
|
||||
depends_on:
|
||||
- openclaw-gateway
|
||||
|
||||
@@ -92,6 +92,14 @@ ensure_control_ui_allowed_origins() {
|
||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
||||
}
|
||||
|
||||
sync_gateway_mode_and_bind() {
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.mode local >/dev/null
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.bind "$OPENCLAW_GATEWAY_BIND" >/dev/null
|
||||
echo "Pinned gateway.mode=local and gateway.bind=$OPENCLAW_GATEWAY_BIND for Docker setup."
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
@@ -154,9 +162,11 @@ fi
|
||||
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR"
|
||||
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
|
||||
# Seed device-identity parent eagerly for Docker Desktop/Windows bind mounts
|
||||
# that reject creating new subdirectories from inside the container.
|
||||
# Seed directory tree eagerly so bind mounts work even on Docker Desktop/Windows
|
||||
# where the container (even as root) cannot create new host subdirectories.
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/identity"
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/agent"
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR/agents/main/sessions"
|
||||
|
||||
export OPENCLAW_CONFIG_DIR
|
||||
export OPENCLAW_WORKSPACE_DIR
|
||||
@@ -338,16 +348,36 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure bind-mounted data directories are writable by the container's `node`
|
||||
# user (uid 1000). Host-created dirs inherit the host user's uid which may
|
||||
# differ, causing EACCES when the container tries to mkdir/write.
|
||||
# Running a brief root container to chown is the portable Docker idiom --
|
||||
# it works regardless of the host uid and doesn't require host-side root.
|
||||
echo ""
|
||||
echo "==> Fixing data-directory permissions"
|
||||
# Use -xdev to restrict chown to the config-dir mount only — without it,
|
||||
# the recursive chown would cross into the workspace bind mount and rewrite
|
||||
# ownership of all user project files on Linux hosts.
|
||||
# After fixing the config dir, only the OpenClaw metadata subdirectory
|
||||
# (.openclaw/) inside the workspace gets chowned, not the user's project files.
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm --user root --entrypoint sh openclaw-cli -c \
|
||||
'find /home/node/.openclaw -xdev -exec chown node:node {} +; \
|
||||
[ -d /home/node/.openclaw/workspace/.openclaw ] && chown -R node:node /home/node/.openclaw/workspace/.openclaw || true'
|
||||
|
||||
echo ""
|
||||
echo "==> Onboarding (interactive)"
|
||||
echo "When prompted:"
|
||||
echo " - Gateway bind: lan"
|
||||
echo " - Gateway auth: token"
|
||||
echo " - Gateway token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo " - Tailscale exposure: Off"
|
||||
echo " - Install Gateway daemon: No"
|
||||
echo "Docker setup pins Gateway mode to local."
|
||||
echo "Gateway runtime bind comes from OPENCLAW_GATEWAY_BIND (default: lan)."
|
||||
echo "Current runtime bind: $OPENCLAW_GATEWAY_BIND"
|
||||
echo "Gateway token: $OPENCLAW_GATEWAY_TOKEN"
|
||||
echo "Tailscale exposure: Off (use host-level tailnet/Tailscale setup separately)."
|
||||
echo "Install Gateway daemon: No (managed by Docker Compose)"
|
||||
echo ""
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --mode local --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Docker gateway defaults"
|
||||
sync_gateway_mode_and_bind
|
||||
|
||||
echo ""
|
||||
echo "==> Control UI origin allowlist"
|
||||
|
||||
16
docs/assets/sponsors/convex.svg
Normal file
16
docs/assets/sponsors/convex.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="126" height="20" viewBox="0 0 126 20" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_5_2)">
|
||||
<path d="M3.18483 17.4674C1.30005 15.782 0.357666 13.2908 0.357666 10.0003C0.357666 6.70977 1.31835 4.2186 3.24278 2.53321C5.16415 0.847812 7.79308 0.00350952 11.1265 0.00350952C12.5111 0.00350952 13.7341 0.103028 14.7985 0.308486C15.8629 0.510733 16.8815 0.854231 17.8544 1.34219V6.68088C16.3417 5.92646 14.6246 5.54765 12.7033 5.54765C11.0106 5.54765 9.76021 5.88473 8.95506 6.55889C8.14686 7.23304 7.74429 8.37911 7.74429 10.0003C7.74429 11.5669 8.14076 12.7001 8.93676 13.4C9.72971 14.103 10.9862 14.453 12.7063 14.453C14.527 14.453 16.2563 14.0067 17.8971 13.1175V18.7034C16.0763 19.5669 13.8073 19.9971 11.0899 19.9971C7.70159 19.9971 5.06961 19.1528 3.18483 17.4674Z" />
|
||||
<path d="M19.538 9.99679C19.538 6.73194 20.4224 4.2504 22.1913 2.54896C23.9602 0.847512 26.6257 0 30.1909 0C33.7805 0 36.4644 0.850722 38.2485 2.54896C40.0296 4.24719 40.9201 6.73194 40.9201 9.99679C40.9201 16.6613 37.3427 19.9936 30.1909 19.9936C23.0879 19.9968 19.538 16.6645 19.538 9.99679ZM32.7497 13.3997C33.2743 12.6966 33.5365 11.5634 33.5365 10C33.5365 8.46228 33.2743 7.33547 32.7497 6.61958C32.2251 5.90369 31.3712 5.54735 30.1909 5.54735C29.0381 5.54735 28.2024 5.9069 27.6901 6.61958C27.1777 7.33547 26.9215 8.46228 26.9215 10C26.9215 11.5666 27.1777 12.6998 27.6901 13.3997C28.2024 14.1027 29.035 14.4526 30.1909 14.4526C31.3712 14.4526 32.2221 14.0995 32.7497 13.3997Z" />
|
||||
<path d="M42.6029 0.404494H49.3704L49.5626 1.86196C50.3067 1.32263 51.2552 0.876404 52.408 0.526485C53.5608 0.176565 54.7533 0 55.9854 0C58.2667 0 59.9319 0.5939 60.9841 1.7817C62.0363 2.9695 62.5608 4.80257 62.5608 7.28732V19.5923H55.3328V8.05458C55.3328 7.19101 55.1467 6.57143 54.7747 6.19262C54.4026 5.8138 53.7804 5.62761 52.9082 5.62761C52.3714 5.62761 51.8194 5.75602 51.2552 6.01284C50.691 6.26966 50.2183 6.60032 49.8309 7.00482V19.5923H42.6029V0.404494Z" />
|
||||
<path d="M62.5818 0.404617H70.1178L73.5794 11.6566L77.0409 0.404617H84.5769L77.3855 19.5924H69.7702L62.5818 0.404617Z" />
|
||||
<path d="M86.8523 17.9422C84.6809 16.2279 83.6653 13.252 83.6653 10.0385C83.6653 6.90851 84.4735 4.33066 86.3186 2.54896C88.1637 0.767255 90.9757 0 94.5256 0C97.792 0 100.36 0.796147 102.236 2.38844C104.108 3.98074 105.047 6.15409 105.047 8.9053V12.2665H91.302C91.6436 13.2648 92.0766 13.9872 93.141 14.4334C94.2054 14.8796 95.6907 15.1011 97.5907 15.1011C98.7252 15.1011 99.8841 15.008 101.061 14.8186C101.476 14.7512 102.159 14.6453 102.519 14.565V19.2295C100.723 19.7432 98.3287 20 95.6297 20C91.9973 19.9968 89.0238 19.6565 86.8523 17.9422ZM97.4534 8.13804C97.4534 7.1878 96.4135 5.14286 94.3243 5.14286C92.4396 5.14286 91.1952 7.1557 91.1952 8.13804H97.4534Z" />
|
||||
<path d="M110.723 9.8364L103.955 0.404617H111.799L125.642 19.5924H117.722L114.645 15.3003L111.567 19.5924H103.684L110.723 9.8364Z" />
|
||||
<path d="M117.548 0.404617H125.356L119.363 8.8059L115.398 3.42227L117.548 0.404617Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5_2">
|
||||
<rect width="126" height="20" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -353,6 +353,38 @@ Notes:
|
||||
- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable).
|
||||
- Override store path: `cron.store` in config.
|
||||
|
||||
## Retry policy
|
||||
|
||||
When a job fails, OpenClaw classifies errors as **transient** (retryable) or **permanent** (disable immediately).
|
||||
|
||||
### Transient errors (retried)
|
||||
|
||||
- Rate limit (429, too many requests, resource exhausted)
|
||||
- Network errors (timeout, ECONNRESET, fetch failed, socket)
|
||||
- Server errors (5xx)
|
||||
- Cloudflare-related errors
|
||||
|
||||
### Permanent errors (no retry)
|
||||
|
||||
- Auth failures (invalid API key, unauthorized)
|
||||
- Config or validation errors
|
||||
- Other non-transient errors
|
||||
|
||||
### Default behavior (no config)
|
||||
|
||||
**One-shot jobs (`schedule.kind: "at"`):**
|
||||
|
||||
- On transient error: retry up to 3 times with exponential backoff (30s → 1m → 5m).
|
||||
- On permanent error: disable immediately.
|
||||
- On success or skip: disable (or delete if `deleteAfterRun: true`).
|
||||
|
||||
**Recurring jobs (`cron` / `every`):**
|
||||
|
||||
- On any error: apply exponential backoff (30s → 1m → 5m → 15m → 60m) before the next scheduled run.
|
||||
- Job stays enabled; backoff resets after the next successful run.
|
||||
|
||||
Configure `cron.retry` to override these defaults (see [Configuration](/automation/cron-jobs#configuration)).
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
@@ -361,6 +393,12 @@ Notes:
|
||||
enabled: true, // default true
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 1, // default 1
|
||||
// Optional: override retry policy for one-shot jobs
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [60000, 120000, 300000],
|
||||
retryOn: ["rate_limit", "network", "server_error"],
|
||||
},
|
||||
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
|
||||
webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode
|
||||
sessionRetention: "24h", // duration string or false
|
||||
@@ -617,7 +655,7 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
- OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors:
|
||||
30s, 1m, 5m, 15m, then 60m between retries.
|
||||
- Backoff resets automatically after the next successful run.
|
||||
- One-shot (`at`) jobs disable after a terminal run (`ok`, `error`, or `skipped`) and do not retry.
|
||||
- One-shot (`at`) jobs retry transient errors (rate limit, network, server_error) up to 3 times with backoff; permanent errors disable immediately. See [Retry policy](/automation/cron-jobs#retry-policy).
|
||||
|
||||
### Telegram delivers to the wrong place
|
||||
|
||||
|
||||
@@ -642,7 +642,8 @@ Default slash command settings:
|
||||
- `/focus <target>` bind current/new thread to a subagent/session target
|
||||
- `/unfocus` remove current thread binding
|
||||
- `/agents` show active runs and binding state
|
||||
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
|
||||
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
|
||||
- `/session max-age <duration|off>` inspect/update hard max age for focused bindings
|
||||
|
||||
Config:
|
||||
|
||||
@@ -651,14 +652,16 @@ Default slash command settings:
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
spawnSubagentSessions: false, // opt-in
|
||||
},
|
||||
},
|
||||
@@ -1000,6 +1003,40 @@ openclaw logs --follow
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Long-running handlers time out or duplicate replies">
|
||||
|
||||
Typical logs:
|
||||
|
||||
- `Listener DiscordMessageListener timed out after 30000ms for event MESSAGE_CREATE`
|
||||
- `Slow listener detected ...`
|
||||
|
||||
Canonical knob:
|
||||
|
||||
- single-account: `channels.discord.eventQueue.listenerTimeout`
|
||||
- multi-account: `channels.discord.accounts.<accountId>.eventQueue.listenerTimeout`
|
||||
|
||||
Recommended baseline:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {
|
||||
eventQueue: {
|
||||
listenerTimeout: 120000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Tune this first before adding alternate timeout controls elsewhere.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Permissions audit mismatches">
|
||||
`channels status --probe` permission checks only work for numeric channel IDs.
|
||||
|
||||
@@ -1046,6 +1083,7 @@ High-signal Discord fields:
|
||||
- startup/auth: `enabled`, `token`, `accounts.*`, `allowBots`
|
||||
- policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*`
|
||||
- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*`
|
||||
- event queue: `eventQueue.listenerTimeout` (canonical), `eventQueue.maxQueueSize`, `eventQueue.maxConcurrency`
|
||||
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
|
||||
- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`
|
||||
|
||||
@@ -109,6 +109,8 @@ On **Permissions**, click **Batch import** and paste:
|
||||
"application:application.app_message_stats.overview:readonly",
|
||||
"application:application:self_manage",
|
||||
"application:bot.menu:write",
|
||||
"cardkit:card:read",
|
||||
"cardkit:card:write",
|
||||
"contact:user.employee_id:readonly",
|
||||
"corehr:file:download",
|
||||
"event:ip_list",
|
||||
@@ -222,6 +224,34 @@ If your tenant is on Lark (international), set the domain to `lark` (or a full d
|
||||
}
|
||||
```
|
||||
|
||||
### Quota optimization flags
|
||||
|
||||
You can reduce Feishu API usage with two optional flags:
|
||||
|
||||
- `typingIndicator` (default `true`): when `false`, skip typing reaction calls.
|
||||
- `resolveSenderNames` (default `true`): when `false`, skip sender profile lookup calls.
|
||||
|
||||
Set them at top level or per account:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
typingIndicator: false,
|
||||
resolveSenderNames: false,
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
typingIndicator: true,
|
||||
resolveSenderNames: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Start + test
|
||||
@@ -315,14 +345,36 @@ After approval, you can chat normally.
|
||||
}
|
||||
```
|
||||
|
||||
### Allow specific users in groups only
|
||||
### Allow specific groups only
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["ou_xxx", "ou_yyy"],
|
||||
// Feishu group IDs (chat_id) look like: oc_xxx
|
||||
groupAllowFrom: ["oc_xxx", "oc_yyy"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Allow specific users to run control commands in a group (e.g. /reset, /new)
|
||||
|
||||
In addition to allowing the group itself, control commands are gated by the **sender** open_id.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["oc_xxx"],
|
||||
groups: {
|
||||
oc_xxx: {
|
||||
// Feishu user IDs (open_id) look like: ou_xxx
|
||||
allowFrom: ["ou_user1", "ou_user2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -426,6 +478,7 @@ openclaw pairing list feishu
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "main",
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
@@ -444,6 +497,8 @@ openclaw pairing list feishu
|
||||
}
|
||||
```
|
||||
|
||||
`defaultAccount` controls which Feishu account is used when outbound APIs do not specify an `accountId` explicitly.
|
||||
|
||||
### Message limits
|
||||
|
||||
- `textChunkLimit`: outbound text chunk size (default: 2000 chars)
|
||||
@@ -529,28 +584,29 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
||||
|
||||
Key options:
|
||||
|
||||
| Setting | Description | Default |
|
||||
| ------------------------------------------------- | ------------------------------- | ---------------- |
|
||||
| `channels.feishu.enabled` | Enable/disable channel | `true` |
|
||||
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
|
||||
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
|
||||
| `channels.feishu.verificationToken` | Required for webhook mode | - |
|
||||
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
|
||||
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
|
||||
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
|
||||
| `channels.feishu.accounts.<id>.appId` | App ID | - |
|
||||
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `open` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
|
||||
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
|
||||
| `channels.feishu.streaming` | Enable streaming card output | `true` |
|
||||
| `channels.feishu.blockStreaming` | Enable block streaming | `true` |
|
||||
| Setting | Description | Default |
|
||||
| ------------------------------------------------- | --------------------------------------- | ---------------- |
|
||||
| `channels.feishu.enabled` | Enable/disable channel | `true` |
|
||||
| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` |
|
||||
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
|
||||
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
|
||||
| `channels.feishu.verificationToken` | Required for webhook mode | - |
|
||||
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
|
||||
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
|
||||
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
|
||||
| `channels.feishu.accounts.<id>.appId` | App ID | - |
|
||||
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `open` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
|
||||
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
|
||||
| `channels.feishu.streaming` | Enable streaming card output | `true` |
|
||||
| `channels.feishu.blockStreaming` | Enable block streaming | `true` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
summary: "Telegram Bot API integration via grammY with setup notes"
|
||||
read_when:
|
||||
- Working on Telegram or grammY pathways
|
||||
title: grammY
|
||||
---
|
||||
|
||||
# grammY Integration (Telegram Bot API)
|
||||
|
||||
# Why grammY
|
||||
|
||||
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
|
||||
- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods.
|
||||
- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context.
|
||||
|
||||
# What we shipped
|
||||
|
||||
- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default.
|
||||
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
Open questions
|
||||
|
||||
- Optional grammY plugins (throttler) if we hit Bot API 429s.
|
||||
- Add more structured media tests (stickers, voice notes).
|
||||
- Make webhook listen port configurable (currently fixed to 8787 unless wired through the gateway).
|
||||
@@ -43,6 +43,5 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/channels/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting).
|
||||
- Model providers are documented separately; see [Model Providers](/providers/models).
|
||||
|
||||
@@ -208,7 +208,8 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||
|
||||
- Native command auto-mode is **off** for Slack (`commands.native: "auto"` does not enable Slack native commands).
|
||||
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
|
||||
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names).
|
||||
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names), with one exception:
|
||||
- register `/agentstatus` for the status command (Slack reserves `/status`)
|
||||
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
|
||||
- Native arg menus now adapt their rendering strategy:
|
||||
- up to 5 options: button blocks
|
||||
@@ -358,7 +359,11 @@ Notes:
|
||||
"channels:read",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"mpim:write",
|
||||
"users:read",
|
||||
"app_mentions:read",
|
||||
"assistant:write",
|
||||
|
||||
@@ -109,13 +109,15 @@ Token resolution order is account-aware. In practice, config values win over env
|
||||
`channels.telegram.dmPolicy` controls direct message access:
|
||||
|
||||
- `pairing` (default)
|
||||
- `allowlist`
|
||||
- `allowlist` (requires at least one sender ID in `allowFrom`)
|
||||
- `open` (requires `allowFrom` to include `"*"`)
|
||||
- `disabled`
|
||||
|
||||
`channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
|
||||
`dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation.
|
||||
The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
|
||||
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet).
|
||||
|
||||
### Finding your Telegram user ID
|
||||
|
||||
@@ -136,10 +138,12 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Tab>
|
||||
|
||||
<Tab title="Group policy and allowlists">
|
||||
There are two independent controls:
|
||||
Two controls apply together:
|
||||
|
||||
1. **Which groups are allowed** (`channels.telegram.groups`)
|
||||
- no `groups` config: all groups allowed
|
||||
- no `groups` config:
|
||||
- with `groupPolicy: "open"`: any group can pass group-ID checks
|
||||
- with `groupPolicy: "allowlist"` (default): groups are blocked until you add `groups` entries (or `"*"`)
|
||||
- `groups` configured: acts as allowlist (explicit IDs or `"*"`)
|
||||
|
||||
2. **Which senders are allowed in groups** (`channels.telegram.groupPolicy`)
|
||||
@@ -148,8 +152,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `disabled`
|
||||
|
||||
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
|
||||
`groupAllowFrom` entries must be numeric Telegram user IDs.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set).
|
||||
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
|
||||
Non-numeric entries are ignored for sender authorization.
|
||||
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
|
||||
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime defaults to fail-closed `groupPolicy="allowlist"` unless `channels.defaults.groupPolicy` is explicitly set.
|
||||
|
||||
Example: allow any member in one specific group:
|
||||
|
||||
@@ -383,17 +390,19 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `react` (`chatId`, `messageId`, `emoji`)
|
||||
- `deleteMessage` (`chatId`, `messageId`)
|
||||
- `editMessage` (`chatId`, `messageId`, `content`)
|
||||
- `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`)
|
||||
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`).
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
|
||||
|
||||
Gating controls:
|
||||
|
||||
- `channels.telegram.actions.sendMessage`
|
||||
- `channels.telegram.actions.editMessage`
|
||||
- `channels.telegram.actions.deleteMessage`
|
||||
- `channels.telegram.actions.reactions`
|
||||
- `channels.telegram.actions.sticker` (default: disabled)
|
||||
|
||||
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
|
||||
|
||||
Reaction removal semantics: [/tools/reactions](/tools/reactions)
|
||||
|
||||
</Accordion>
|
||||
@@ -610,6 +619,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- set `channels.telegram.webhookSecret` (required when webhook URL is set)
|
||||
- optional `channels.telegram.webhookPath` (default `/telegram-webhook`)
|
||||
- optional `channels.telegram.webhookHost` (default `127.0.0.1`)
|
||||
- optional `channels.telegram.webhookPort` (default `8787`)
|
||||
|
||||
Default local listener for webhook mode binds to `127.0.0.1:8787`.
|
||||
|
||||
@@ -627,7 +637,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- DM history controls:
|
||||
- `channels.telegram.dmHistoryLimit`
|
||||
- `channels.telegram.dms["<user_id>"].historyLimit`
|
||||
- outbound Telegram API retries are configurable via `channels.telegram.retry`.
|
||||
- `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors.
|
||||
|
||||
CLI send target can be numeric chat ID or username:
|
||||
|
||||
@@ -716,9 +726,10 @@ Primary reference:
|
||||
- `channels.telegram.botToken`: bot token (BotFather).
|
||||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
|
||||
- `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
- Multi-account precedence:
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
@@ -735,13 +746,14 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `off`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
|
||||
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+.
|
||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||
@@ -749,6 +761,7 @@ Primary reference:
|
||||
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
|
||||
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||
- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`).
|
||||
- `channels.telegram.webhookPort`: local webhook bind port (default `8787`).
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
@@ -762,7 +775,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||
- command/menu: `commands.native`, `customCommands`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user