Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bbf5105f1 | ||
|
|
5b193d014e | ||
|
|
fc7a63a4de | ||
|
|
aec1869d32 | ||
|
|
377169959d | ||
|
|
ba497ce57d | ||
|
|
5e7d12fefa | ||
|
|
a019d3cd83 | ||
|
|
8c6a592523 | ||
|
|
47a1774dc0 | ||
|
|
2bc0c57f18 | ||
|
|
f0705a928a | ||
|
|
22f9322905 | ||
|
|
6795e78edf | ||
|
|
31620fea3a | ||
|
|
6b6f2b5414 | ||
|
|
c498348a34 | ||
|
|
00fc731d64 | ||
|
|
d80d112e09 | ||
|
|
65d723d53c | ||
|
|
fb3fae43c0 | ||
|
|
41108f497b | ||
|
|
beefda7f60 | ||
|
|
74cdc1cf3e | ||
|
|
7f3be083c1 | ||
|
|
b8012a2281 | ||
|
|
95ea67de28 | ||
|
|
1fbd84da39 | ||
|
|
beb5b1ad58 | ||
|
|
77a67484ea | ||
|
|
0b4e70e38b | ||
|
|
8f0b5d2d97 | ||
|
|
0e3e4f269d | ||
|
|
d6c5ee86c5 | ||
|
|
3772a29557 | ||
|
|
7831e0040e | ||
|
|
3780f3152c | ||
|
|
3146f8bdbc | ||
|
|
256080e2a2 | ||
|
|
47510e2912 | ||
|
|
0c06276b48 | ||
|
|
d66d5cc17e | ||
|
|
df0c51a63b | ||
|
|
c34da133f6 | ||
|
|
f237222bc9 | ||
|
|
2a4ccaf993 | ||
|
|
ac50a14b6a | ||
|
|
06f71d883c | ||
|
|
9ace6af3df | ||
|
|
9062f60e3d | ||
|
|
2307756892 | ||
|
|
7008493f03 | ||
|
|
b5a89e8907 | ||
|
|
ae58838cc5 | ||
|
|
9a4fc3e086 | ||
|
|
0241f1a29c | ||
|
|
801e44f4eb | ||
|
|
856ce06fda | ||
|
|
d406d3a058 | ||
|
|
0b8e8144af | ||
|
|
ad26026802 | ||
|
|
c2b8f9a7c3 | ||
|
|
ba79977f07 | ||
|
|
16e2193911 | ||
|
|
bb5d26ba9e | ||
|
|
59f9073e21 | ||
|
|
982f85bf90 | ||
|
|
acdf70e928 | ||
|
|
d182f7e4b2 | ||
|
|
790079c3b6 | ||
|
|
dda6d7f9e1 | ||
|
|
256f0fc765 | ||
|
|
e1f320276e | ||
|
|
c61bd6c84d | ||
|
|
8a343aedf2 | ||
|
|
cd729e83b6 | ||
|
|
cfb36525ab | ||
|
|
6f58a9d643 | ||
|
|
0913329b03 | ||
|
|
402b04a68c | ||
|
|
4a68b4add4 | ||
|
|
a74c4db948 | ||
|
|
0fc5ccb76c | ||
|
|
98a745b3df | ||
|
|
24009ed00f | ||
|
|
fceab511b3 | ||
|
|
c6421136f9 | ||
|
|
2f8b75d86e | ||
|
|
97ec5d52c3 | ||
|
|
89fcb40557 | ||
|
|
5c705ab675 | ||
|
|
2f21b94a76 | ||
|
|
6f1ae147da | ||
|
|
f2d503ad04 | ||
|
|
57ee34839d | ||
|
|
82d8526732 | ||
|
|
742027a447 | ||
|
|
efed2ae30f | ||
|
|
54830e8401 | ||
|
|
ce1a8d70d9 | ||
|
|
cd719a8c85 | ||
|
|
3df53836ca | ||
|
|
7bb058215d | ||
|
|
272015c701 | ||
|
|
21a27e3b65 | ||
|
|
22516437b7 | ||
|
|
ea53f1bec7 | ||
|
|
33bf5cf42a | ||
|
|
c976799f8c | ||
|
|
f973b9e0e5 | ||
|
|
60321352aa | ||
|
|
6d60224c93 | ||
|
|
2b2434d239 | ||
|
|
f8bea661fc | ||
|
|
86225d0eb6 | ||
|
|
3351c972e7 | ||
|
|
460e170f7a | ||
|
|
1a2d39bdf9 | ||
|
|
99325040f8 | ||
|
|
568fcbda54 | ||
|
|
f4b186a9d3 | ||
|
|
d862ae17eb | ||
|
|
9f73131621 | ||
|
|
99310a5bbb | ||
|
|
1673bf2d44 | ||
|
|
4c656ea22f | ||
|
|
7707e3d887 | ||
|
|
ba204d0330 | ||
|
|
cbb327227a | ||
|
|
14fa2f47f5 | ||
|
|
579da8cc9b | ||
|
|
5693d7d733 | ||
|
|
07c8fdffd1 | ||
|
|
d3f4db649f | ||
|
|
abbe237cc0 | ||
|
|
ac4a65ddfd | ||
|
|
693215723a | ||
|
|
5f0e474be1 | ||
|
|
0e201c4c18 | ||
|
|
d12ca22b19 | ||
|
|
c7b80c28a1 | ||
|
|
5c2288218f | ||
|
|
0844fa38a8 | ||
|
|
3ed33c5856 | ||
|
|
b3e466ccb6 | ||
|
|
875cf9a054 | ||
|
|
ca85d217ec | ||
|
|
6652b1f4f3 | ||
|
|
9fe04f5659 | ||
|
|
5b9e51bfaa | ||
|
|
cdea744725 | ||
|
|
44365f2e27 | ||
|
|
888dbd7d11 | ||
|
|
76ddfc4a9e | ||
|
|
7950a646c3 | ||
|
|
09819f8b2e | ||
|
|
69daa24869 | ||
|
|
35214b6dec | ||
|
|
fe6bf6966b | ||
|
|
e0276ed4b4 | ||
|
|
fce487669b | ||
|
|
e6ba373d08 | ||
|
|
d4b3d504e4 | ||
|
|
2b2376d4c0 | ||
|
|
51bdf01e2e | ||
|
|
9d29fbbf80 | ||
|
|
a40fc50e5e | ||
|
|
df4e4534f4 | ||
|
|
fca6e466b1 | ||
|
|
0321174519 | ||
|
|
c452f8c430 | ||
|
|
079c1d8786 | ||
|
|
0677567cdd | ||
|
|
7fe7c30b17 | ||
|
|
cc1d8060c4 | ||
|
|
428a82e734 | ||
|
|
cc235fc312 | ||
|
|
249f97d1ed | ||
|
|
3e9310d6cd | ||
|
|
9051c5891e | ||
|
|
56d94e6974 | ||
|
|
e6a96bea47 | ||
|
|
cf82e37c36 | ||
|
|
4fb3e0500a | ||
|
|
9c7d51429e | ||
|
|
c1985443fd | ||
|
|
17a27fd312 | ||
|
|
557ffdbe35 | ||
|
|
e9bfe34850 | ||
|
|
1a4540d386 | ||
|
|
a0c4b1e061 | ||
|
|
e275ba8d2e | ||
|
|
db7eeee07b | ||
|
|
84d5f24f5f | ||
|
|
42948b70e3 | ||
|
|
28d3bd03b2 | ||
|
|
6148f862b9 | ||
|
|
0a32610b37 | ||
|
|
514759bde7 | ||
|
|
2eb27ffb4a | ||
|
|
2ce24fdbf8 | ||
|
|
e9ae10e569 | ||
|
|
a1940418fb | ||
|
|
6fdc62c008 | ||
|
|
5e5cb7a292 | ||
|
|
f5ab3e41c5 | ||
|
|
036bdde764 | ||
|
|
691bf85d7e | ||
|
|
4482965d80 | ||
|
|
fdca8fb592 | ||
|
|
c7c32210e6 | ||
|
|
316a04f606 | ||
|
|
c4da2afb22 | ||
|
|
9eaa45a291 | ||
|
|
81a9439eb2 | ||
|
|
be9b550209 | ||
|
|
6653813cb9 | ||
|
|
cf1278295d | ||
|
|
cdb5ddb2da | ||
|
|
1cdebb68a0 | ||
|
|
fece42ce0a | ||
|
|
c5867b2876 | ||
|
|
43e257e7de | ||
|
|
9dcdeb15ec | ||
|
|
060a209ecb | ||
|
|
e1e3da946f | ||
|
|
49a9f74753 | ||
|
|
74b19843ae | ||
|
|
d691e28675 | ||
|
|
2a5f0d6063 | ||
|
|
66a0813e44 | ||
|
|
64d6d25d65 | ||
|
|
b443c20cef | ||
|
|
5e8c8367f3 | ||
|
|
2b0f846f1b | ||
|
|
e7713a28ae | ||
|
|
7948d071e0 | ||
|
|
fb23717102 | ||
|
|
3d959c46d0 | ||
|
|
4cdd61eb78 | ||
|
|
6d08d84011 | ||
|
|
f6cafd1a15 | ||
|
|
5792887883 | ||
|
|
e82ee731bf | ||
|
|
5e09aae4ca | ||
|
|
740f7b0fb6 | ||
|
|
7510a6f66a | ||
|
|
1ff7d458a5 | ||
|
|
c3528fb201 | ||
|
|
3f5dff35f8 | ||
|
|
08bfe2b263 | ||
|
|
42645a7e0a | ||
|
|
7d4c8ef6b2 | ||
|
|
a1d7b8db6f | ||
|
|
4a3a4558e2 | ||
|
|
1b83fc85cd | ||
|
|
c1a10b6056 | ||
|
|
841a9b4c8a | ||
|
|
f3db02018f | ||
|
|
4cbaee59cd | ||
|
|
0d10aa4098 | ||
|
|
f3f8aa5397 | ||
|
|
4970af6bb9 | ||
|
|
a48aebc78c | ||
|
|
26bbddde8f | ||
|
|
b48a556de5 | ||
|
|
aab5c490dc | ||
|
|
d54cc49d66 | ||
|
|
0cef22ef83 | ||
|
|
7b2f712e20 | ||
|
|
1a92127dfa | ||
|
|
26a05292b9 | ||
|
|
caaa79bb76 | ||
|
|
b80c0d85e0 | ||
|
|
0641281cfe | ||
|
|
f414853d70 | ||
|
|
7c677c5057 | ||
|
|
969c7d1c8e | ||
|
|
b202480a66 | ||
|
|
9e80764c2b | ||
|
|
f5a5320f8f | ||
|
|
7389fc0e25 | ||
|
|
ce915d3438 | ||
|
|
3ef910d23e | ||
|
|
845b26a73b | ||
|
|
e0545e2f94 | ||
|
|
01341d983c | ||
|
|
0d68e10dd7 | ||
|
|
e6a60c0dc5 | ||
|
|
7dbd5acbb1 | ||
|
|
7a87f3cfb8 | ||
|
|
b817225fb8 | ||
|
|
a097c848bb | ||
|
|
a47d3e3e35 | ||
|
|
4d4bcaab1e | ||
|
|
265a3dff27 | ||
|
|
97fe3972c8 | ||
|
|
7c91ce2fa7 | ||
|
|
951993db17 | ||
|
|
357a1a982b | ||
|
|
f6f69b408f | ||
|
|
98399b85e3 | ||
|
|
38a773f245 | ||
|
|
e9e2e5026c | ||
|
|
8649de6199 | ||
|
|
3885a2a20f | ||
|
|
dde9fddae4 | ||
|
|
3a08e6df9d | ||
|
|
f427bec31c | ||
|
|
67e0739bec | ||
|
|
c7022cc139 | ||
|
|
65a0de8979 | ||
|
|
d0134722af | ||
|
|
efc7181aa0 | ||
|
|
3729d269d0 | ||
|
|
7dd8a7f2e3 | ||
|
|
56bbcfc3ee | ||
|
|
eec6212cdf | ||
|
|
a5b3b8743a | ||
|
|
4dd9072a2b | ||
|
|
073285409b | ||
|
|
41da61dd6a | ||
|
|
35e8dae939 | ||
|
|
05e77b69c4 | ||
|
|
745eefe0be | ||
|
|
d7165b4720 | ||
|
|
7b1163f75c | ||
|
|
507f5623f4 | ||
|
|
5ace7c9c66 | ||
|
|
3b35b762cb | ||
|
|
dbd3865e3b | ||
|
|
6bf1e6fa06 | ||
|
|
1c0170554e | ||
|
|
1d79254053 | ||
|
|
974ab5a8dd | ||
|
|
eaebf4b896 | ||
|
|
cf747e1b82 | ||
|
|
455fe15bd1 | ||
|
|
c4d0eb9350 | ||
|
|
10d95348b1 | ||
|
|
f86b1cf6a1 | ||
|
|
259e9cfccf | ||
|
|
7318b20f55 | ||
|
|
322a36f365 | ||
|
|
b8b20eac6d | ||
|
|
1fb123d701 | ||
|
|
138f4bd850 | ||
|
|
20abf31093 | ||
|
|
4abc551f9e | ||
|
|
67707763f7 | ||
|
|
df8915cf5c | ||
|
|
a1d16c61ec | ||
|
|
64b5eb8279 | ||
|
|
c66122c255 | ||
|
|
b792175ec5 | ||
|
|
b944bee121 | ||
|
|
88ff2f79d5 | ||
|
|
c3fa1fb736 | ||
|
|
e9eb9edc23 | ||
|
|
e8018d8008 | ||
|
|
694a10f604 | ||
|
|
b2378c01ea | ||
|
|
e2451484d9 | ||
|
|
dccdc950bf | ||
|
|
dd7be2bfd8 | ||
|
|
66b05163e3 | ||
|
|
7789bf6907 | ||
|
|
8b6abe0151 | ||
|
|
25eb40ab31 | ||
|
|
0336c1fa37 | ||
|
|
09541de076 | ||
|
|
2583fb66cc | ||
|
|
037ea92679 | ||
|
|
38f65e7053 | ||
|
|
ebbc416d4b | ||
|
|
13c4f8da2b | ||
|
|
099b8c9fa5 | ||
|
|
1638d32e1c | ||
|
|
13e1c93c74 | ||
|
|
affbd48a3f | ||
|
|
00f83ca7af | ||
|
|
441bd25f90 | ||
|
|
128df57005 | ||
|
|
a80cd26341 | ||
|
|
700212608a | ||
|
|
8fb064ed70 | ||
|
|
a92eb1f33d | ||
|
|
2454e67e09 | ||
|
|
862a490038 | ||
|
|
ffc57d5f20 | ||
|
|
e96654ced1 | ||
|
|
dd763b45e1 | ||
|
|
2710841801 | ||
|
|
a9e1eabcbd | ||
|
|
30a2e47390 | ||
|
|
f7076c38ea | ||
|
|
e6d522493b | ||
|
|
c286573f5c | ||
|
|
aef18b7359 | ||
|
|
17e183f5cf | ||
|
|
a53d8ed4e4 | ||
|
|
755e329b01 | ||
|
|
765c466d6d | ||
|
|
cf3becfb2e | ||
|
|
b508f642b2 | ||
|
|
3fcee21ff7 | ||
|
|
b01cb41950 | ||
|
|
7642cbb5b7 | ||
|
|
7a6334d920 | ||
|
|
d96bc38bea | ||
|
|
a31a569d52 | ||
|
|
0d3aacd316 | ||
|
|
ece8a3e701 | ||
|
|
ceb3980b93 | ||
|
|
01eba1b8d9 | ||
|
|
cf28ea0d1c | ||
|
|
41dd3b11b7 | ||
|
|
5a1687484c | ||
|
|
6143338116 | ||
|
|
39c232548c | ||
|
|
e2a93e17f9 | ||
|
|
0b990443de | ||
|
|
02fe19effa | ||
|
|
920cc9ac38 | ||
|
|
ba22890205 | ||
|
|
a59cfa7670 | ||
|
|
7cdd7c5333 | ||
|
|
e3379b960e | ||
|
|
7b675864a8 | ||
|
|
3b853b329f | ||
|
|
537c515dde | ||
|
|
238afbc2f8 | ||
|
|
2a71c20ee4 | ||
|
|
40c66b1741 | ||
|
|
94ad808028 | ||
|
|
0c8b5ed59a | ||
|
|
56fe23549c | ||
|
|
867d7e5d25 | ||
|
|
a0cd761c96 | ||
|
|
7c3502f031 | ||
|
|
61ab07ced3 | ||
|
|
281c6d6069 | ||
|
|
82634dfe3b | ||
|
|
9be3394bac | ||
|
|
4228ee326c | ||
|
|
fa1110e4d3 | ||
|
|
050c47d3a7 | ||
|
|
161895ed1a | ||
|
|
aeffdc3632 | ||
|
|
ecf0da1796 | ||
|
|
990fafa988 | ||
|
|
ceb0a8b3e3 | ||
|
|
3b283f3167 | ||
|
|
2b29d08064 | ||
|
|
86ed3de1c1 | ||
|
|
acf035d848 | ||
|
|
cab71c9711 | ||
|
|
c17440f5b4 | ||
|
|
fd566bda14 | ||
|
|
e47dccbe87 | ||
|
|
2a172f9779 | ||
|
|
ce630a6381 | ||
|
|
a882798143 | ||
|
|
44f9327087 | ||
|
|
e654676148 | ||
|
|
840e266b5d | ||
|
|
7d89fa2591 | ||
|
|
5f67c023a2 | ||
|
|
af3e5b299c | ||
|
|
b3b4013637 | ||
|
|
9ad341d668 | ||
|
|
d7a8d9a1c7 | ||
|
|
2d36ae6326 | ||
|
|
208ba02a4a | ||
|
|
4cdb21c5cd | ||
|
|
5d6cc8125b | ||
|
|
237933069e | ||
|
|
99660db73f | ||
|
|
68fa676cbf | ||
|
|
8794f002d5 | ||
|
|
d52ef185b1 | ||
|
|
3ca77c46c7 | ||
|
|
89d5d807ee | ||
|
|
9e3427a37e | ||
|
|
7ce25ecfca | ||
|
|
1ca77bee26 | ||
|
|
5dbc7cc68d | ||
|
|
0d45c78917 | ||
|
|
31fb4f7c8b | ||
|
|
e9acb6fad5 | ||
|
|
ab402e1178 | ||
|
|
293701f520 | ||
|
|
7b38ba0e65 | ||
|
|
5d8ee8fc28 | ||
|
|
3e2e4be680 | ||
|
|
3863fe6412 | ||
|
|
2b71ea21ad | ||
|
|
36f21c5a4f | ||
|
|
cf90bd9c86 | ||
|
|
5f159c43c5 | ||
|
|
c02613e15f | ||
|
|
32cd1175fb | ||
|
|
0152e053e1 | ||
|
|
8d1e73edc7 | ||
|
|
a5f51eadf1 | ||
|
|
4ac21a4f63 | ||
|
|
91fdf2aa25 | ||
|
|
44614d4a7d | ||
|
|
0e9f617667 | ||
|
|
7e7e348a14 | ||
|
|
cc3d0d1ef7 | ||
|
|
5b608718bb | ||
|
|
3a6ab81549 | ||
|
|
c48681b2f0 | ||
|
|
86d786cbc0 | ||
|
|
ec653b7b80 | ||
|
|
cbc34e1c8a | ||
|
|
1f37d94f9e | ||
|
|
3ee0e041fa | ||
|
|
4074f4fffa | ||
|
|
7286fd6e3f | ||
|
|
4b608117a2 | ||
|
|
47b4d245aa | ||
|
|
36ff508fec | ||
|
|
772b5fdf0f | ||
|
|
eace21dcae | ||
|
|
163080b609 | ||
|
|
4938fbffa8 | ||
|
|
d5db20c296 | ||
|
|
415cb857d9 | ||
|
|
a641250da6 | ||
|
|
4d674a3f17 |
No files matched your search
@@ -14,6 +14,21 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Checkout submodules (retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git submodule sync --recursive
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
if: matrix.runtime == 'node'
|
if: matrix.runtime == 'node'
|
||||||
@@ -26,7 +41,9 @@ jobs:
|
|||||||
if: matrix.runtime == 'bun'
|
if: matrix.runtime == 'bun'
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.x"
|
# bun.sh downloads currently fail with:
|
||||||
|
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||||
|
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||||
|
|
||||||
- name: Setup Node.js (tooling for bun)
|
- name: Setup Node.js (tooling for bun)
|
||||||
if: matrix.runtime == 'bun'
|
if: matrix.runtime == 'bun'
|
||||||
@@ -93,15 +110,30 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Checkout submodules (retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git submodule sync --recursive
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Select Xcode 26.1
|
- name: Select Xcode 26.1
|
||||||
run: |
|
run: |
|
||||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Install SwiftLint / SwiftFormat
|
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||||
run: |
|
run: |
|
||||||
brew install swiftlint swiftformat
|
brew install xcodegen swiftlint swiftformat
|
||||||
|
|
||||||
- name: Show toolchain
|
- name: Show toolchain
|
||||||
run: |
|
run: |
|
||||||
@@ -116,7 +148,204 @@ jobs:
|
|||||||
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||||
|
|
||||||
- name: Swift build (release)
|
- name: Swift build (release)
|
||||||
run: swift build --package-path apps/macos --configuration release
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if swift build --package-path apps/macos --configuration release; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Swift tests
|
- name: Swift tests (coverage)
|
||||||
run: swift test --package-path apps/macos --parallel
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Generate iOS project
|
||||||
|
run: |
|
||||||
|
cd apps/ios
|
||||||
|
xcodegen generate
|
||||||
|
|
||||||
|
- name: iOS tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||||
|
DEST_ID="$(
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
def sh(args: list[str]) -> str:
|
||||||
|
return subprocess.check_output(args, text=True).strip()
|
||||||
|
|
||||||
|
# Prefer an already-created iPhone simulator if it exists.
|
||||||
|
devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"]))
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
for runtime, devs in (devices.get("devices") or {}).items():
|
||||||
|
for dev in devs or []:
|
||||||
|
if not dev.get("isAvailable"):
|
||||||
|
continue
|
||||||
|
name = str(dev.get("name") or "")
|
||||||
|
udid = str(dev.get("udid") or "")
|
||||||
|
if not udid or not name.startswith("iPhone"):
|
||||||
|
continue
|
||||||
|
candidates.append((name, udid))
|
||||||
|
|
||||||
|
candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0]))
|
||||||
|
if candidates:
|
||||||
|
print(candidates[0][1])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Otherwise, create one from the newest available iOS runtime.
|
||||||
|
runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or []
|
||||||
|
ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")]
|
||||||
|
if not ios:
|
||||||
|
print("No available iOS runtimes found.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def version_key(rt: dict) -> tuple[int, ...]:
|
||||||
|
parts: list[int] = []
|
||||||
|
for p in str(rt.get("version") or "0").split("."):
|
||||||
|
try:
|
||||||
|
parts.append(int(p))
|
||||||
|
except ValueError:
|
||||||
|
parts.append(0)
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
ios.sort(key=version_key, reverse=True)
|
||||||
|
runtime = ios[0]
|
||||||
|
runtime_id = str(runtime.get("identifier") or "")
|
||||||
|
if not runtime_id:
|
||||||
|
print("Missing iOS runtime identifier.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
supported = runtime.get("supportedDeviceTypes") or []
|
||||||
|
iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"]
|
||||||
|
if not iphones:
|
||||||
|
print("No iPhone device types for iOS runtime.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
iphones.sort(
|
||||||
|
key=lambda dt: (
|
||||||
|
0 if "iPhone 16" in str(dt.get("name") or "") else 1,
|
||||||
|
str(dt.get("name") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
device_type_id = str(iphones[0].get("identifier") or "")
|
||||||
|
if not device_type_id:
|
||||||
|
print("Missing iPhone device type identifier.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}"
|
||||||
|
udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id])
|
||||||
|
if not udid:
|
||||||
|
print("Failed to create iPhone simulator.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(udid)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "Using iOS Simulator id: $DEST_ID"
|
||||||
|
xcodebuild test \
|
||||||
|
-project apps/ios/Clawdis.xcodeproj \
|
||||||
|
-scheme Clawdis \
|
||||||
|
-destination "platform=iOS Simulator,id=$DEST_ID" \
|
||||||
|
-resultBundlePath "$RESULT_BUNDLE_PATH" \
|
||||||
|
-enableCodeCoverage YES
|
||||||
|
|
||||||
|
- name: iOS coverage summary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||||
|
xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH"
|
||||||
|
|
||||||
|
- name: iOS coverage gate (43%)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult"
|
||||||
|
RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
target_name = "Clawdis.app"
|
||||||
|
minimum = 0.43
|
||||||
|
|
||||||
|
report = json.loads(
|
||||||
|
subprocess.check_output(
|
||||||
|
["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]],
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
target_coverage = None
|
||||||
|
for target in report.get("targets", []):
|
||||||
|
if target.get("name") == target_name:
|
||||||
|
target_coverage = float(target["lineCoverage"])
|
||||||
|
break
|
||||||
|
|
||||||
|
if target_coverage is None:
|
||||||
|
print(f"Could not find coverage for target: {target_name}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)")
|
||||||
|
if target_coverage + 1e-12 < minimum:
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Checkout submodules (retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git submodule sync --recursive
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
yes | sdkmanager --licenses >/dev/null
|
||||||
|
sdkmanager --install \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-36" \
|
||||||
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
|
- name: Android unit tests + debug build
|
||||||
|
working-directory: apps/android
|
||||||
|
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||||
@@ -4,12 +4,34 @@ dist
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
.worktrees/
|
||||||
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
apps/macos/.build/
|
apps/macos/.build/
|
||||||
|
apps/shared/ClawdisKit/.build/
|
||||||
bin/clawdis-mac
|
bin/clawdis-mac
|
||||||
|
bin/docs-list
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
apps/macos/.swiftpm/
|
apps/macos/.swiftpm/
|
||||||
Core/
|
Core/
|
||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
apps/ios/.swiftpm/
|
apps/ios/.swiftpm/
|
||||||
|
|
||||||
|
# Vendor build artifacts
|
||||||
|
vendor/a2ui/renderers/lit/dist/
|
||||||
|
|
||||||
|
# fastlane (iOS)
|
||||||
|
apps/ios/fastlane/README.md
|
||||||
|
apps/ios/fastlane/report.xml
|
||||||
|
apps/ios/fastlane/Preview.html
|
||||||
|
apps/ios/fastlane/screenshots/
|
||||||
|
apps/ios/fastlane/test_output/
|
||||||
|
apps/ios/fastlane/logs/
|
||||||
|
|
||||||
|
# fastlane build artifacts (local)
|
||||||
|
apps/ios/*.ipa
|
||||||
|
apps/ios/*.dSYM.zip
|
||||||
|
|
||||||
|
# provisioning profiles (local)
|
||||||
|
apps/ios/*.mobileprovision
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "Peekaboo"]
|
||||||
|
path = Peekaboo
|
||||||
|
url = https://github.com/steipete/Peekaboo.git
|
||||||
|
branch = main
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
--allman false
|
--allman false
|
||||||
|
|
||||||
# Exclusions
|
# Exclusions
|
||||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata
|
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
|
||||||
|
|
||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
@@ -34,15 +32,18 @@ READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
|||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
||||||
- Pi/Tau sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks. Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.tau/agent/sessions/clawdis/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||||
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||||
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||||
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2.0.0 — Unreleased
|
## 2.0.0-beta1 — 2025-12-19
|
||||||
|
|
||||||
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app.
|
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||||
- Pi/Tau only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||||
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||||
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||||
- Gateway background helpers were removed; run `clawdis gateway --verbose` under your supervisor of choice if you want it detached.
|
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||||
|
|
||||||
|
### Gateway, nodes, and automation
|
||||||
|
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
||||||
|
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
||||||
|
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
||||||
|
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
||||||
|
|
||||||
### macOS companion app
|
### macOS companion app
|
||||||
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||||
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
||||||
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
||||||
|
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
||||||
|
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
||||||
|
|
||||||
|
### iOS node
|
||||||
|
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
||||||
|
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
||||||
|
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
||||||
|
|
||||||
### WhatsApp & agent experience
|
### WhatsApp & agent experience
|
||||||
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
||||||
@@ -27,7 +43,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||||
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||||
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||||
- RPC stability: stdin/stdout loop for Tau/Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||||
|
|
||||||
### Security & hardening
|
### Security & hardening
|
||||||
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||||
@@ -36,7 +52,7 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
||||||
- CLI gateway now auto-starts WhatsApp and Telegram when configured (single `gateway` command with `--provider` selector); text/media sends still use `--provider telegram`; webhook/proxy options documented.
|
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
||||||
|
|
||||||
## 1.5.0 — 2025-12-05
|
## 1.5.0 — 2025-12-05
|
||||||
|
|
||||||
@@ -49,8 +65,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||||
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||||
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||||
- Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||||
- Pi/Tau sessions now write to `~/.clawdis/sessions/` by default (legacy `~/.tau/agent/sessions/clawdis` files are copied over when present).
|
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||||
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||||
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||||
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||||
@@ -68,18 +84,18 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
## 1.4.0 — 2025-12-03
|
## 1.4.0 — 2025-12-03
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||||
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||||
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||||
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||||
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||||
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||||
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||||
- **Pi/Tau stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Tau RPC process to avoid cold starts.
|
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||||
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||||
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||||
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||||
- **Tau completion signal:** RPC now resolves on Tau’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||||
|
|
||||||
### Reliability & UX
|
### Reliability & UX
|
||||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||||
@@ -101,8 +117,8 @@ First Clawdis release post rebrand. This is a semver-major because we dropped le
|
|||||||
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||||
- MIME sniffing and redirect handling for downloads/hosted media.
|
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||||
- Tau RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||||
- Tau (pi) session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||||
|
|||||||
@@ -10,18 +10,28 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||||
<a href="https://www.npmjs.com/package/clawdis"><img src="https://img.shields.io/npm/v/clawdis.svg?style=for-the-badge" alt="npm version"></a>
|
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CLAWDIS** is a WhatsApp- and Telegram-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
|
**CLAWDIS** is a TypeScript/Node gateway that bridges WhatsApp (Web/Baileys) and Telegram (Bot API/grammY) to a local coding agent (**Pi**).
|
||||||
|
It’s like having a genius lobster in your pocket 24/7 — but with a real control plane, companion apps, and a network model that won’t corrupt sessions.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌──────────┐ ┌─────────────┐
|
WhatsApp / Telegram
|
||||||
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
│
|
||||||
│ Telegram │ ───▶ │ 🦞⏱️💙 │ ◀─── │ (Pi/Tau) │
|
▼
|
||||||
│ (You) │ ◀─── │ │ │ │
|
┌──────────────────────────┐
|
||||||
└─────────────┘ └──────────┘ └─────────────┘
|
│ Gateway │ ws://127.0.0.1:18789 (default: loopback)
|
||||||
|
│ (control UI) │ http://127.0.0.1:18789/ui/
|
||||||
|
│ (single source) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||||
|
└───────────┬───────────────┘
|
||||||
|
│
|
||||||
|
├─ Pi agent (RPC)
|
||||||
|
├─ CLI (clawdis …)
|
||||||
|
├─ Control UI (browser)
|
||||||
|
├─ macOS app (Clawdis.app)
|
||||||
|
└─ iOS node via Bridge + pairing
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why "CLAWDIS"?
|
## Why "CLAWDIS"?
|
||||||
@@ -34,49 +44,81 @@ Because every space lobster needs a time-and-space machine. The Doctor has a TAR
|
|||||||
|
|
||||||
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
|
- 📱 **WhatsApp Integration** — Personal WhatsApp Web (Baileys)
|
||||||
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
- ✈️ **Telegram (Bot API)** — DMs and groups via grammY
|
||||||
- 🤖 **AI Agent Gateway** — Pi/Tau only (Pi CLI in RPC mode)
|
- 🛰️ **Gateway control plane** — One long-lived gateway owns provider state; clients connect over WebSocket
|
||||||
- 💬 **Session Management** — Per-sender conversation context
|
- 🤖 **Agent runtime** — Pi only (Pi CLI in RPC mode), with tool streaming
|
||||||
|
- 💬 **Sessions** — Direct chats collapse into `main` by default; groups are isolated
|
||||||
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||||
|
- 🧭 **Clawd Browser** — Dedicated Chrome/Chromium profile with tabs + screenshot control (no interference with your daily browser)
|
||||||
- 👥 **Group Chat Support** — Mention-based triggering
|
- 👥 **Group Chat Support** — Mention-based triggering
|
||||||
- 📎 **Media Support** — Images, audio, documents, voice notes
|
- 📎 **Media Support** — Images, audio, documents, voice notes
|
||||||
- 🎤 **Voice Transcription** — Whisper integration
|
- 🎤 **Voice & transcription hooks** — Voice Wake (macOS/iOS) + optional transcription pipeline
|
||||||
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
||||||
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, on-device Voice Wake, model/config editor
|
- 🖥️ **macOS Companion (Clawdis.app)** — Menu bar controls, Voice Wake, WebChat, onboarding, remote gateway control
|
||||||
|
- 📱 **iOS node** — Pairs as a node, exposes a Canvas surface, forwards voice wake transcripts
|
||||||
|
|
||||||
Only the Pi/Tau CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
|
Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been removed.
|
||||||
|
|
||||||
|
## Network model (the “new reality”)
|
||||||
|
|
||||||
|
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
|
||||||
|
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` by default.
|
||||||
|
- To expose it on your tailnet, set `gateway.bind: "tailnet"` (or run `clawdis gateway --bind tailnet`) and set `CLAWDIS_GATEWAY_TOKEN` (required for non-loopback binds).
|
||||||
|
- The browser Control UI is served from the Gateway at `http://<host>:18789/ui/` when assets are built.
|
||||||
|
- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`.
|
||||||
|
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
|
||||||
|
- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`.
|
||||||
|
|
||||||
|
## Codebase
|
||||||
|
|
||||||
|
- **TypeScript (ESM)**: CLI + Gateway live in `src/` and run on Node ≥ 22.
|
||||||
|
- **macOS app (Swift)**: menu bar companion lives in `apps/macos/`.
|
||||||
|
- **iOS app (Swift)**: iOS node prototype lives in `apps/ios/`.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
Mac signing tip: set `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` in your shell profile so `scripts/restart-mac.sh` signs with your cert (defaults to ad-hoc). Debug bundle ID remains `com.steipete.clawdis.debug`.
|
|
||||||
|
|
||||||
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
|
Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI both use the host runtime; install via Homebrew or official installers before running `clawdis`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install
|
# From source (recommended while the npm package is still settling)
|
||||||
npm install -g clawdis
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm ui:build
|
||||||
|
|
||||||
# Link your WhatsApp
|
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
|
||||||
clawdis login
|
pnpm clawdis login
|
||||||
|
|
||||||
# Send a message
|
|
||||||
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
|
|
||||||
|
|
||||||
# Talk directly to the agent (no WhatsApp send)
|
|
||||||
clawdis agent --to +1234567890 --message "Ship checklist" --thinking high
|
|
||||||
|
|
||||||
# Start the gateway (WebSocket control plane)
|
# Start the gateway (WebSocket control plane)
|
||||||
clawdis gateway --port 18789 --verbose
|
pnpm clawdis gateway --port 18789 --verbose
|
||||||
|
|
||||||
|
# Open the browser Control UI (after ui:build)
|
||||||
|
# http://127.0.0.1:18789/ui/
|
||||||
|
|
||||||
|
# Send a WhatsApp message (WhatsApp sends go through the Gateway)
|
||||||
|
pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
|
||||||
|
|
||||||
|
# Talk to the agent (optionally deliver back to WhatsApp/Telegram)
|
||||||
|
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||||
|
|
||||||
# If the port is busy, force-kill listeners then start
|
# If the port is busy, force-kill listeners then start
|
||||||
clawdis gateway --force
|
pnpm clawdis gateway --force
|
||||||
```
|
```
|
||||||
|
|
||||||
## macOS Companion App (Clawdis.app)
|
### Agent workspace + skills
|
||||||
|
|
||||||
- **On-device Voice Wake:** listens for wake words (e.g. “Claude”) using Apple’s on-device speech recognizer (macOS 26+). macOS still shows the standard Speech/Mic permissions prompt, but audio stays on device.
|
Clawdis runs the embedded agent with its working directory set to the agent workspace (default: `~/clawd`, configurable via `inbound.workspace`).
|
||||||
- **Push-to-talk (Right Option hold):** hold right Option to speak; the voice overlay shows live partials and sends when you release.
|
|
||||||
- **Config tab:** pick the model from your local Pi model catalog (`pi-mono/packages/ai/src/models.generated.ts`), or enter a custom model ID; edit session store path and context tokens.
|
- Workspace files injected into the system prompt: `AGENTS.md`, `SOUL.md`, `TOOLS.md`
|
||||||
- **Voice settings:** language + additional languages, mic picker, live level meter, trigger-word table, and a built-in test harness.
|
- Custom skills: `<workspace>/skills/<skill-name>/SKILL.md` (default: `~/clawd/skills/<skill-name>/SKILL.md`; only this location is scanned)
|
||||||
- **Menu bar toggle:** enable/disable Voice Wake from the menu bar; respects Dock-icon preference.
|
|
||||||
|
## Companion Apps
|
||||||
|
|
||||||
|
### macOS Companion (Clawdis.app)
|
||||||
|
|
||||||
|
- A menu bar app that can start/stop the Gateway, show health/presence, and provide a local ops UI.
|
||||||
|
- Instances UI shows friendly hardware model names (from the vendored MIT dataset under `apps/macos/Sources/Clawdis/Resources/DeviceModels/`).
|
||||||
|
- **Voice Wake** (on-device speech recognition) and Push-to-talk overlay.
|
||||||
|
- **WebChat** embed + debug tooling (logs, status, heartbeats, sessions).
|
||||||
|
- Hosts **PeekabooBridge** for UI automation brokering (for clawd workflows).
|
||||||
|
|
||||||
### Voice Wake reply routing
|
### Voice Wake reply routing
|
||||||
|
|
||||||
@@ -90,6 +132,16 @@ If delivery fails (e.g. WhatsApp disconnected / Telegram token missing), Clawdis
|
|||||||
|
|
||||||
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
|
Build/run the mac app with `./scripts/restart-mac.sh` (packages, installs, and launches), or `swift build --package-path apps/macos && open dist/Clawdis.app`.
|
||||||
|
|
||||||
|
### iOS node (internal)
|
||||||
|
|
||||||
|
The iOS node app is an internal/prototype app that connects as a **remote node**:
|
||||||
|
|
||||||
|
- **Voice trigger:** forwards transcripts into the Gateway (agent runs + wakeups).
|
||||||
|
- **Canvas screen:** a WKWebView + `<canvas>` surface the agent can control (via `canvas.eval` / `canvas.snapshot` over `node.invoke`).
|
||||||
|
- **Discovery + pairing:** finds the bridge via Bonjour (`_clawdis-bridge._tcp`) and uses Gateway-owned pairing (`clawdis nodes pending|approve`); `clawdis nodes status` shows paired nodes + capabilities.
|
||||||
|
|
||||||
|
Runbook: `docs/ios/connect.md`
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Create `~/.clawdis/clawdis.json`:
|
Create `~/.clawdis/clawdis.json`:
|
||||||
@@ -97,16 +149,19 @@ Create `~/.clawdis/clawdis.json`:
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
inbound: {
|
inbound: {
|
||||||
allowFrom: ["+1234567890"],
|
allowFrom: ["+1234567890"]
|
||||||
reply: {
|
}
|
||||||
mode: "command",
|
}
|
||||||
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
|
```
|
||||||
session: {
|
|
||||||
scope: "per-sender",
|
Optional: enable/configure clawd’s dedicated browser control (defaults are already on):
|
||||||
idleMinutes: 1440
|
|
||||||
},
|
```json5
|
||||||
heartbeatMinutes: 10
|
{
|
||||||
}
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
color: "#FF4500"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -114,12 +169,18 @@ Create `~/.clawdis/clawdis.json`:
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Configuration Guide](./docs/configuration.md)
|
- [Configuration Guide](./docs/configuration.md)
|
||||||
- [Agent Integration](./docs/agents.md)
|
- [Gateway runbook](./docs/gateway.md)
|
||||||
|
- [Web surfaces (Control UI)](./docs/web.md)
|
||||||
|
- [Discovery + transports](./docs/discovery.md)
|
||||||
|
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
|
||||||
|
- [Agent Runtime](./docs/agent.md)
|
||||||
- [Group Chats](./docs/group-messages.md)
|
- [Group Chats](./docs/group-messages.md)
|
||||||
- [Security](./docs/security.md)
|
- [Security](./docs/security.md)
|
||||||
- [Troubleshooting](./docs/troubleshooting.md)
|
- [Troubleshooting](./docs/troubleshooting.md)
|
||||||
- [The Lore](./docs/lore.md) 🦞
|
- [The Lore](./docs/lore.md) 🦞
|
||||||
- [Telegram (Bot API)](./docs/telegram.md)
|
- [Telegram (Bot API)](./docs/telegram.md)
|
||||||
|
- [iOS node runbook](./docs/ios/connect.md)
|
||||||
|
- [macOS app spec](./docs/clawdis-mac.md)
|
||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
@@ -132,29 +193,32 @@ CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setu
|
|||||||
|
|
||||||
## Provider
|
## Provider
|
||||||
|
|
||||||
|
If you’re running from source, use `pnpm clawdis …` instead of `clawdis …`.
|
||||||
|
|
||||||
### WhatsApp Web
|
### WhatsApp Web
|
||||||
```bash
|
```bash
|
||||||
clawdis login # Scan QR code
|
clawdis login # scan QR, store creds
|
||||||
clawdis gateway # Start listening (WS on 127.0.0.1:18789)
|
clawdis gateway # run Gateway (WS on 127.0.0.1:18789)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Telegram (Bot API)
|
### Telegram (Bot API)
|
||||||
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text and media send work via `clawdis send --provider telegram`. The unified `clawdis gateway` starts WhatsApp and, when `TELEGRAM_BOT_TOKEN` or `telegram.botToken` is set, Telegram too (use `--provider` to force web|telegram|all). Webhook mode: `--webhook --port … --webhook-secret … --webhook-url …` (or register via BotFather). See `docs/telegram.md` for setup and limits.
|
Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebChat, with groups kept isolated. Text/media sends work via `clawdis send --provider telegram` (reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken`). Webhook mode is supported; see `docs/telegram.md` for setup and limits.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `clawdis login` | Link WhatsApp Web via QR |
|
| `clawdis login` | Link WhatsApp Web via QR |
|
||||||
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). Always uses the Gateway WS; requires a running gateway. |
|
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. |
|
||||||
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
||||||
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
|
| `clawdis browser ...` | Manage clawd’s dedicated browser (status/tabs/open/screenshot). |
|
||||||
|
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--bind`, `--token`, `--force`, `--verbose`. |
|
||||||
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
|
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
|
||||||
|
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
|
||||||
|
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
|
||||||
|
| `clawdis nodes ...` | Manage nodes (pairing + status) via the Gateway. |
|
||||||
| `clawdis status` | Web session health + session store summary |
|
| `clawdis status` | Web session health + session store summary |
|
||||||
| `clawdis health` | Reports cached provider state from the running gateway. |
|
| `clawdis health` | Reports cached provider state from the running gateway. |
|
||||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
|
||||||
|
|
||||||
#### Gateway client params (WS only)
|
#### Gateway client params (WS only)
|
||||||
- `--url` (default `ws://127.0.0.1:18789`)
|
- `--url` (default `ws://127.0.0.1:18789`)
|
||||||
@@ -174,8 +238,8 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
|
|||||||
|
|
||||||
### Sessions, surfaces, and WebChat
|
### Sessions, surfaces, and WebChat
|
||||||
|
|
||||||
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
||||||
- WebChat always attaches to the `main` session and hydrates the full Tau history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
- WebChat attaches to `main` and hydrates history from `~/.clawdis/sessions/<SessionId>.jsonl`, so desktop view mirrors WhatsApp/Telegram turns.
|
||||||
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
|
- Inbound contexts carry a `Surface` hint (e.g., `whatsapp`, `webchat`, `telegram`) for logging; replies still go back to the originating surface deterministically.
|
||||||
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
- Every inbound message is wrapped for the agent as `[Surface FROM HOST/IP TIMESTAMP] body`:
|
||||||
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
- WhatsApp: `[WhatsApp +15551234567 2025-12-09 12:34] …`
|
||||||
@@ -186,7 +250,7 @@ In chat, send `/status` to see if the agent is reachable, how much context the s
|
|||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||||
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
|
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Pi, security testing
|
||||||
- **Clawd** 🦞 — The space lobster who demanded a better name
|
- **Clawd** 🦞 — The space lobster who demanded a better name
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -6,5 +6,16 @@
|
|||||||
<title>Clawdis Updates</title>
|
<title>Clawdis Updates</title>
|
||||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||||
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||||
|
<item>
|
||||||
|
<title>Clawdis 2.0.0-beta1</title>
|
||||||
|
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||||
|
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
|
||||||
|
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
|
||||||
|
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
|
||||||
|
sparkle:version="2.0.0-beta1"
|
||||||
|
sparkle:shortVersionString="2.0.0-beta1"
|
||||||
|
length="72410016"
|
||||||
|
type="application/octet-stream" />
|
||||||
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle/
|
||||||
|
**/build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
**/*.iml
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## Clawdis Node (Android) (internal)
|
||||||
|
|
||||||
|
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
|
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
|
||||||
|
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).
|
||||||
|
|
||||||
|
## Open in Android Studio
|
||||||
|
- Open the folder `apps/android`.
|
||||||
|
|
||||||
|
## Build / Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/android
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
./gradlew :app:installDebug
|
||||||
|
./gradlew :app:testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||||
|
|
||||||
|
## Connect / Pair
|
||||||
|
|
||||||
|
1) Start the gateway (on your “master” machine):
|
||||||
|
```bash
|
||||||
|
pnpm clawdis gateway --port 18789 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
2) In the Android app:
|
||||||
|
- Open **Settings**
|
||||||
|
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
||||||
|
|
||||||
|
3) Approve pairing (on the gateway machine):
|
||||||
|
```bash
|
||||||
|
clawdis nodes pending
|
||||||
|
clawdis nodes approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
More details: `docs/android/connect.md`.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
- Discovery:
|
||||||
|
- Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
|
||||||
|
- Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
|
||||||
|
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
|
||||||
|
- Camera:
|
||||||
|
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||||
|
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.steipete.clawdis.node"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.steipete.clawdis.node"
|
||||||
|
minSdk = 31
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val composeBom = platform("androidx.compose:compose-bom:2025.12.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.1")
|
||||||
|
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||||
|
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
|
||||||
|
// 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.9.0")
|
||||||
|
|
||||||
|
implementation("androidx.security:security-crypto:1.1.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")
|
||||||
|
|
||||||
|
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||||
|
implementation("dnsjava:dnsjava:3.6.3")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".NodeApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:theme="@style/Theme.ClawdisNode">
|
||||||
|
<service
|
||||||
|
android:name=".NodeForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
enum class CameraHudKind {
|
||||||
|
Photo,
|
||||||
|
Recording,
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CameraHudState(
|
||||||
|
val token: Long,
|
||||||
|
val kind: CameraHudKind,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
|
||||||
|
object DeviceNames {
|
||||||
|
fun bestDefaultNodeName(context: Context): String {
|
||||||
|
val deviceName =
|
||||||
|
runCatching {
|
||||||
|
Settings.Global.getString(context.contentResolver, "device_name")
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
if (deviceName.isNotEmpty()) return deviceName
|
||||||
|
|
||||||
|
val model =
|
||||||
|
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
return model.ifEmpty { "Android Node" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import com.steipete.clawdis.node.ui.RootScreen
|
||||||
|
import com.steipete.clawdis.node.ui.ClawdisTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val viewModel: MainViewModel by viewModels()
|
||||||
|
private lateinit var permissionRequester: PermissionRequester
|
||||||
|
private lateinit var screenCaptureRequester: ScreenCaptureRequester
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
|
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
||||||
|
applyImmersiveMode()
|
||||||
|
requestDiscoveryPermissionsIfNeeded()
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
NodeForegroundService.start(this)
|
||||||
|
permissionRequester = PermissionRequester(this)
|
||||||
|
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||||
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
|
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||||
|
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||||
|
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.preventSleep.collect { enabled ->
|
||||||
|
if (enabled) {
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
} else {
|
||||||
|
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
ClawdisTheme {
|
||||||
|
Surface(modifier = Modifier) {
|
||||||
|
RootScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
applyImmersiveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasFocus)
|
||||||
|
if (hasFocus) {
|
||||||
|
applyImmersiveMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
viewModel.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
viewModel.setForeground(false)
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyImmersiveMode() {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||||
|
controller.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestDiscoveryPermissionsIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES), 100)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 101)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33) return
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
|
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||||
|
|
||||||
|
val canvas: CanvasController = runtime.canvas
|
||||||
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
|
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||||
|
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
|
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||||
|
|
||||||
|
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||||
|
val statusText: StateFlow<String> = runtime.statusText
|
||||||
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
|
|
||||||
|
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
|
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||||
|
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||||
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||||
|
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||||
|
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||||
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
|
|
||||||
|
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||||
|
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
||||||
|
val chatMessages = runtime.chatMessages
|
||||||
|
val chatError: StateFlow<String?> = runtime.chatError
|
||||||
|
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
||||||
|
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
||||||
|
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
||||||
|
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
||||||
|
val chatSessions = runtime.chatSessions
|
||||||
|
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||||
|
|
||||||
|
fun setForeground(value: Boolean) {
|
||||||
|
runtime.setForeground(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
runtime.setDisplayName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
runtime.setCameraEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreventSleep(value: Boolean) {
|
||||||
|
runtime.setPreventSleep(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
runtime.setManualEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
runtime.setManualHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
runtime.setManualPort(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
runtime.setWakeWords(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetWakeWordsDefaults() {
|
||||||
|
runtime.resetWakeWordsDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||||
|
runtime.setVoiceWakeMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
|
runtime.connect(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectManual() {
|
||||||
|
runtime.connectManual()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
runtime.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChat(sessionKey: String = "main") {
|
||||||
|
runtime.loadChat(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshChat() {
|
||||||
|
runtime.refreshChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshChatSessions(limit: Int? = null) {
|
||||||
|
runtime.refreshChatSessions(limit = limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChatThinkingLevel(level: String) {
|
||||||
|
runtime.setChatThinkingLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchChatSession(sessionKey: String) {
|
||||||
|
runtime.switchChatSession(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abortChat() {
|
||||||
|
runtime.abortChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||||
|
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class NodeApp : Application() {
|
||||||
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class NodeForegroundService : Service() {
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
private var lastRequiresMic = false
|
||||||
|
private var didStartForeground = false
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ensureChannel()
|
||||||
|
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||||
|
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||||
|
|
||||||
|
val runtime = (application as NodeApp).runtime
|
||||||
|
notificationJob =
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
runtime.statusText,
|
||||||
|
runtime.serverName,
|
||||||
|
runtime.isConnected,
|
||||||
|
runtime.voiceWakeMode,
|
||||||
|
runtime.voiceWakeIsListening,
|
||||||
|
) { status, server, connected, voiceMode, voiceListening ->
|
||||||
|
Quint(status, server, connected, voiceMode, voiceListening)
|
||||||
|
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||||
|
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||||
|
val voiceSuffix =
|
||||||
|
if (voiceMode == VoiceWakeMode.Always) {
|
||||||
|
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val text = (server?.let { "$status · $it" } ?: status) + voiceSuffix
|
||||||
|
|
||||||
|
val requiresMic =
|
||||||
|
voiceMode == VoiceWakeMode.Always && hasRecordAudioPermission()
|
||||||
|
startForegroundWithTypes(
|
||||||
|
notification = buildNotification(title = title, text = text),
|
||||||
|
requiresMic = requiresMic,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
(application as NodeApp).runtime.disconnect()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Connection",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "Clawdis node connection status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
val flags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.addAction(0, "Disconnect", stopPending)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(notification: Notification) {
|
||||||
|
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mgr.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT < 29) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didStartForeground && requiresMic == lastRequiresMic) {
|
||||||
|
updateNotification(notification)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRequiresMic = requiresMic
|
||||||
|
val types =
|
||||||
|
if (requiresMic) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
|
} else {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
}
|
||||||
|
startForeground(NOTIFICATION_ID, notification, types)
|
||||||
|
didStartForeground = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasRecordAudioPermission(): Boolean {
|
||||||
|
return (
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "connection"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)
|
||||||
@@ -0,0 +1,847 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.chat.ChatController
|
||||||
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
|
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||||
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
|
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||||
|
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
||||||
|
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class NodeRuntime(context: Context) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val prefs = SecurePrefs(appContext)
|
||||||
|
val canvas = CanvasController()
|
||||||
|
val camera = CameraCaptureManager(appContext)
|
||||||
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val voiceWake: VoiceWakeManager by lazy {
|
||||||
|
VoiceWakeManager(
|
||||||
|
context = appContext,
|
||||||
|
scope = scope,
|
||||||
|
onCommand = { command ->
|
||||||
|
session.sendEvent(
|
||||||
|
event = "agent.request",
|
||||||
|
payloadJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("message", JsonPrimitive(command))
|
||||||
|
put("sessionKey", JsonPrimitive("main"))
|
||||||
|
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||||
|
put("deliver", JsonPrimitive(false))
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceWakeIsListening: StateFlow<Boolean>
|
||||||
|
get() = voiceWake.isListening
|
||||||
|
|
||||||
|
val voiceWakeStatusText: StateFlow<String>
|
||||||
|
get() = voiceWake.statusText
|
||||||
|
|
||||||
|
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
|
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Offline")
|
||||||
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private val cameraHudSeq = AtomicLong(0)
|
||||||
|
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||||
|
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||||
|
|
||||||
|
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||||
|
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||||
|
|
||||||
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
|
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||||
|
|
||||||
|
private val _isForeground = MutableStateFlow(true)
|
||||||
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
|
private val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { name, remote ->
|
||||||
|
_statusText.value = "Connected"
|
||||||
|
_serverName.value = name
|
||||||
|
_remoteAddress.value = remote
|
||||||
|
_isConnected.value = true
|
||||||
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
|
},
|
||||||
|
onDisconnected = { message -> handleSessionDisconnected(message) },
|
||||||
|
onEvent = { event, payloadJson ->
|
||||||
|
handleBridgeEvent(event, payloadJson)
|
||||||
|
},
|
||||||
|
onInvoke = { req ->
|
||||||
|
handleInvoke(req.command, req.paramsJson)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||||
|
|
||||||
|
private fun handleSessionDisconnected(message: String) {
|
||||||
|
_statusText.value = message
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
_isConnected.value = false
|
||||||
|
chat.onDisconnected(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||||
|
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||||
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||||
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
||||||
|
private var didAutoConnect = false
|
||||||
|
private var suppressWakeWordsSync = false
|
||||||
|
private var wakeWordsSyncJob: Job? = null
|
||||||
|
|
||||||
|
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||||
|
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||||
|
val chatMessages: StateFlow<List<ChatMessage>> = chat.messages
|
||||||
|
val chatError: StateFlow<String?> = chat.errorText
|
||||||
|
val chatHealthOk: StateFlow<Boolean> = chat.healthOk
|
||||||
|
val chatThinkingLevel: StateFlow<String> = chat.thinkingLevel
|
||||||
|
val chatStreamingAssistantText: StateFlow<String?> = chat.streamingAssistantText
|
||||||
|
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = chat.pendingToolCalls
|
||||||
|
val chatSessions: StateFlow<List<ChatSessionEntry>> = chat.sessions
|
||||||
|
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
voiceWakeMode,
|
||||||
|
isForeground,
|
||||||
|
externalAudioCaptureActive,
|
||||||
|
wakeWords,
|
||||||
|
) { mode, foreground, externalAudio, words ->
|
||||||
|
Quad(mode, foreground, externalAudio, words)
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
.collect { (mode, foreground, externalAudio, words) ->
|
||||||
|
voiceWake.setTriggerWords(words)
|
||||||
|
|
||||||
|
val shouldListen =
|
||||||
|
when (mode) {
|
||||||
|
VoiceWakeMode.Off -> false
|
||||||
|
VoiceWakeMode.Foreground -> foreground
|
||||||
|
VoiceWakeMode.Always -> true
|
||||||
|
} && !externalAudio
|
||||||
|
|
||||||
|
if (!shouldListen) {
|
||||||
|
voiceWake.stop(statusText = if (mode == VoiceWakeMode.Off) "Off" else "Paused")
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasRecordAudioPermission()) {
|
||||||
|
voiceWake.stop(statusText = "Microphone permission required")
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceWake.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
bridges.collect { list ->
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
||||||
|
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didAutoConnect) return@collect
|
||||||
|
if (_isConnected.value) return@collect
|
||||||
|
|
||||||
|
val token = prefs.loadBridgeToken()
|
||||||
|
if (token.isNullOrBlank()) return@collect
|
||||||
|
|
||||||
|
if (manualEnabled.value) {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isNotEmpty() && port in 1..65535) {
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||||
|
if (targetStableId.isEmpty()) return@collect
|
||||||
|
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(value: Boolean) {
|
||||||
|
_isForeground.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
prefs.setDisplayName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
prefs.setCameraEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreventSleep(value: Boolean) {
|
||||||
|
prefs.setPreventSleep(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
prefs.setManualEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
prefs.setManualHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
prefs.setManualPort(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
prefs.setWakeWords(words)
|
||||||
|
scheduleWakeWordsSyncIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetWakeWordsDefaults() {
|
||||||
|
setWakeWords(SecurePrefs.defaultWakeWords)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||||
|
prefs.setVoiceWakeMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
|
scope.launch {
|
||||||
|
_statusText.value = "Connecting…"
|
||||||
|
val storedToken = prefs.loadBridgeToken()
|
||||||
|
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { null }
|
||||||
|
|
||||||
|
val invokeCommands =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCanvasCommand.Present.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||||
|
add(ClawdisScreenCommand.Record.rawValue)
|
||||||
|
if (cameraEnabled.value) {
|
||||||
|
add(ClawdisCameraCommand.Snap.rawValue)
|
||||||
|
add(ClawdisCameraCommand.Clip.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resolved =
|
||||||
|
if (storedToken.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Pairing…"
|
||||||
|
val caps = buildList {
|
||||||
|
add(ClawdisCapability.Canvas.rawValue)
|
||||||
|
add(ClawdisCapability.Screen.rawValue)
|
||||||
|
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||||
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgePairingClient().pairAndHello(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps = caps,
|
||||||
|
commands = invokeCommands,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Failed: pairing required"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
|
prefs.saveBridgeToken(authToken)
|
||||||
|
session.connect(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = authToken,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCapability.Canvas.rawValue)
|
||||||
|
add(ClawdisCapability.Screen.rawValue)
|
||||||
|
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||||
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commands = invokeCommands,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasRecordAudioPermission(): Boolean {
|
||||||
|
return (
|
||||||
|
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectManual() {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||||
|
_statusText.value = "Failed: invalid manual host/port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
session.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
|
scope.launch {
|
||||||
|
val trimmed = payloadJson.trim()
|
||||||
|
if (trimmed.isEmpty()) return@launch
|
||||||
|
|
||||||
|
val root =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(trimmed).asObjectOrNull() ?: return@launch
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val userActionObj = (root["userAction"] as? JsonObject) ?: root
|
||||||
|
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||||
|
java.util.UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
|
val name = (userActionObj["name"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
if (name.isEmpty()) return@launch
|
||||||
|
|
||||||
|
val surfaceId =
|
||||||
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
|
val sourceComponentId =
|
||||||
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
|
val sessionKey = "main"
|
||||||
|
val message =
|
||||||
|
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName = name,
|
||||||
|
sessionKey = sessionKey,
|
||||||
|
surfaceId = surfaceId,
|
||||||
|
sourceComponentId = sourceComponentId,
|
||||||
|
host = displayName.value,
|
||||||
|
instanceId = instanceId.value.lowercase(),
|
||||||
|
contextJson = contextJson,
|
||||||
|
)
|
||||||
|
|
||||||
|
val connected = isConnected.value
|
||||||
|
var error: String? = null
|
||||||
|
if (connected) {
|
||||||
|
try {
|
||||||
|
session.sendEvent(
|
||||||
|
event = "agent.request",
|
||||||
|
payloadJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("message", JsonPrimitive(message))
|
||||||
|
put("sessionKey", JsonPrimitive(sessionKey))
|
||||||
|
put("thinking", JsonPrimitive("low"))
|
||||||
|
put("deliver", JsonPrimitive(false))
|
||||||
|
put("key", JsonPrimitive(actionId))
|
||||||
|
}.toString(),
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
error = e.message ?: "send failed"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error = "bridge not connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.eval(
|
||||||
|
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
|
actionId = actionId,
|
||||||
|
ok = connected && error == null,
|
||||||
|
error = error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChat(sessionKey: String = "main") {
|
||||||
|
chat.load(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshChat() {
|
||||||
|
chat.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshChatSessions(limit: Int? = null) {
|
||||||
|
chat.refreshSessions(limit = limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChatThinkingLevel(level: String) {
|
||||||
|
chat.setThinkingLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchChatSession(sessionKey: String) {
|
||||||
|
chat.switchSession(sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abortChat() {
|
||||||
|
chat.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||||
|
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
|
if (event == "voicewake.changed") {
|
||||||
|
if (payloadJson.isNullOrBlank()) return
|
||||||
|
try {
|
||||||
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
|
val array = payload["triggers"] as? JsonArray ?: return
|
||||||
|
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||||
|
applyWakeWordsFromGateway(triggers)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.handleBridgeEvent(event, payloadJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||||
|
suppressWakeWordsSync = true
|
||||||
|
prefs.setWakeWords(words)
|
||||||
|
suppressWakeWordsSync = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWakeWordsSyncIfNeeded() {
|
||||||
|
if (suppressWakeWordsSync) return
|
||||||
|
if (!_isConnected.value) return
|
||||||
|
|
||||||
|
val snapshot = prefs.wakeWords.value
|
||||||
|
wakeWordsSyncJob?.cancel()
|
||||||
|
wakeWordsSyncJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(650)
|
||||||
|
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||||
|
val params = """{"triggers":[$jsonList]}"""
|
||||||
|
try {
|
||||||
|
session.request("voicewake.set", params)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshWakeWordsFromGateway() {
|
||||||
|
if (!_isConnected.value) return
|
||||||
|
try {
|
||||||
|
val res = session.request("voicewake.get", "{}")
|
||||||
|
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||||
|
val array = payload["triggers"] as? JsonArray ?: return
|
||||||
|
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||||
|
applyWakeWordsFromGateway(triggers)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
|
if (
|
||||||
|
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||||
|
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
||||||
|
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
||||||
|
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
||||||
|
) {
|
||||||
|
if (!isForeground.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "CAMERA_DISABLED",
|
||||||
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (command) {
|
||||||
|
ClawdisCanvasCommand.Present.rawValue -> {
|
||||||
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
|
canvas.navigate(url)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||||
|
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||||
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
|
canvas.navigate(url)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||||
|
val js =
|
||||||
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
|
?: return BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: javaScript required",
|
||||||
|
)
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
canvas.eval(js)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
|
}
|
||||||
|
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||||
|
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||||
|
val base64 =
|
||||||
|
try {
|
||||||
|
canvas.snapshotBase64(
|
||||||
|
format = snapshotParams.format,
|
||||||
|
quality = snapshotParams.quality,
|
||||||
|
maxWidth = snapshotParams.maxWidth,
|
||||||
|
)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||||
|
}
|
||||||
|
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||||
|
val ready = ensureA2uiReady()
|
||||||
|
if (!ready) {
|
||||||
|
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready")
|
||||||
|
}
|
||||||
|
val res = canvas.eval(a2uiResetJS)
|
||||||
|
BridgeSession.InvokeResult.ok(res)
|
||||||
|
}
|
||||||
|
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||||
|
val messages =
|
||||||
|
try {
|
||||||
|
decodeA2uiMessages(command, paramsJson)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||||
|
}
|
||||||
|
val ready = ensureA2uiReady()
|
||||||
|
if (!ready) {
|
||||||
|
return BridgeSession.InvokeResult.error(code = "A2UI_NOT_READY", message = "A2UI not ready")
|
||||||
|
}
|
||||||
|
val js = a2uiApplyMessagesJS(messages)
|
||||||
|
val res = canvas.eval(js)
|
||||||
|
BridgeSession.InvokeResult.ok(res)
|
||||||
|
}
|
||||||
|
ClawdisCameraCommand.Snap.rawValue -> {
|
||||||
|
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||||
|
triggerCameraFlash()
|
||||||
|
val res =
|
||||||
|
try {
|
||||||
|
camera.snap(paramsJson)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
|
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||||
|
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||||
|
}
|
||||||
|
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
ClawdisCameraCommand.Clip.rawValue -> {
|
||||||
|
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||||
|
if (includeAudio) externalAudioCaptureActive.value = true
|
||||||
|
try {
|
||||||
|
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
|
||||||
|
val res =
|
||||||
|
try {
|
||||||
|
camera.clip(paramsJson)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
|
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||||
|
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||||
|
}
|
||||||
|
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
} finally {
|
||||||
|
if (includeAudio) externalAudioCaptureActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClawdisScreenCommand.Record.rawValue -> {
|
||||||
|
val res =
|
||||||
|
try {
|
||||||
|
screenRecorder.record(paramsJson)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
|
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: unknown command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerCameraFlash() {
|
||||||
|
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||||
|
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
|
||||||
|
val token = cameraHudSeq.incrementAndGet()
|
||||||
|
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
|
||||||
|
|
||||||
|
if (autoHideMs != null && autoHideMs > 0) {
|
||||||
|
scope.launch {
|
||||||
|
delay(autoHideMs)
|
||||||
|
if (_cameraHud.value?.token == token) _cameraHud.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||||
|
val raw = (err.message ?: "").trim()
|
||||||
|
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
|
||||||
|
|
||||||
|
val idx = raw.indexOf(':')
|
||||||
|
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||||
|
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||||
|
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||||
|
// Preserve full string for callers/logging, but keep the returned message human-friendly.
|
||||||
|
return code to "$code: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureA2uiReady(): Boolean {
|
||||||
|
try {
|
||||||
|
val already = canvas.eval(a2uiReadyCheckJS)
|
||||||
|
if (already == "true") return true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the default canvas scaffold is loaded; A2UI is now hosted there.
|
||||||
|
canvas.navigate("")
|
||||||
|
repeat(50) {
|
||||||
|
try {
|
||||||
|
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||||
|
if (ready == "true") return true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
delay(120)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||||
|
val raw = paramsJson?.trim().orEmpty()
|
||||||
|
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||||
|
|
||||||
|
val obj =
|
||||||
|
json.parseToJsonElement(raw) as? JsonObject
|
||||||
|
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||||
|
|
||||||
|
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
|
val hasMessagesArray = obj["messages"] is JsonArray
|
||||||
|
|
||||||
|
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||||
|
val jsonl = jsonlField
|
||||||
|
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||||
|
val messages =
|
||||||
|
jsonl
|
||||||
|
.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.mapIndexed { idx, line ->
|
||||||
|
val el = json.parseToJsonElement(line)
|
||||||
|
val msg =
|
||||||
|
el as? JsonObject
|
||||||
|
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||||
|
validateA2uiV0_8(msg, idx + 1)
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
return JsonArray(messages).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||||
|
val out =
|
||||||
|
arr.mapIndexed { idx, el ->
|
||||||
|
val msg =
|
||||||
|
el as? JsonObject
|
||||||
|
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||||
|
validateA2uiV0_8(msg, idx + 1)
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
return JsonArray(out).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||||
|
if (msg.containsKey("createSurface")) {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||||
|
val matched = msg.keys.filter { allowed.contains(it) }
|
||||||
|
if (matched.size != 1) {
|
||||||
|
val found = msg.keys.sorted().joinToString(", ")
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||||
|
|
||||||
|
private const val a2uiReadyCheckJS: String =
|
||||||
|
"""
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
private const val a2uiResetJS: String =
|
||||||
|
"""
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||||
|
return globalThis.clawdisA2UI.reset();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
|
||||||
|
private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||||
|
return """
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||||
|
const messages = $messagesJson;
|
||||||
|
return globalThis.clawdisA2UI.applyMessages(messages);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonString(): String {
|
||||||
|
val escaped =
|
||||||
|
this.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.Manifest
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class PermissionRequester(private val activity: ComponentActivity) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||||
|
|
||||||
|
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||||
|
val p = pending
|
||||||
|
pending = null
|
||||||
|
p?.complete(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestIfMissing(
|
||||||
|
permissions: List<String>,
|
||||||
|
timeoutMs: Long = 20_000,
|
||||||
|
): Map<String, Boolean> =
|
||||||
|
mutex.withLock {
|
||||||
|
val missing =
|
||||||
|
permissions.filter { perm ->
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) {
|
||||||
|
return permissions.associateWith { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
val needsRationale =
|
||||||
|
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||||
|
if (needsRationale) {
|
||||||
|
val proceed = showRationaleDialog(missing)
|
||||||
|
if (!proceed) {
|
||||||
|
return permissions.associateWith { perm ->
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||||
|
pending = deferred
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
launcher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||||
|
val merged =
|
||||||
|
permissions.associateWith { perm ->
|
||||||
|
val nowGranted =
|
||||||
|
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||||
|
result[perm] == true || nowGranted
|
||||||
|
}
|
||||||
|
|
||||||
|
val denied =
|
||||||
|
merged.filterValues { !it }.keys.filter {
|
||||||
|
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||||
|
}
|
||||||
|
if (denied.isNotEmpty()) {
|
||||||
|
showSettingsDialog(denied)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Permission required")
|
||||||
|
.setMessage(buildRationaleMessage(permissions))
|
||||||
|
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||||
|
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||||
|
.setOnCancelListener { cont.resume(false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSettingsDialog(permissions: List<String>) {
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Enable permission in Settings")
|
||||||
|
.setMessage(buildSettingsMessage(permissions))
|
||||||
|
.setPositiveButton("Open Settings") { _, _ ->
|
||||||
|
val intent =
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", activity.packageName, null),
|
||||||
|
)
|
||||||
|
activity.startActivity(intent)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
|
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
|
return "Please enable ${labels.joinToString(", ")} in Android Settings to continue."
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionLabel(permission: String): String =
|
||||||
|
when (permission) {
|
||||||
|
Manifest.permission.CAMERA -> "Camera"
|
||||||
|
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||||
|
else -> permission
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
||||||
|
data class CaptureResult(val resultCode: Int, val data: Intent)
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var pending: CompletableDeferred<CaptureResult?>? = null
|
||||||
|
|
||||||
|
private val launcher: ActivityResultLauncher<Intent> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val p = pending
|
||||||
|
pending = null
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && data != null) {
|
||||||
|
p?.complete(CaptureResult(result.resultCode, data))
|
||||||
|
} else {
|
||||||
|
p?.complete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
|
||||||
|
mutex.withLock {
|
||||||
|
val proceed = showRationaleDialog()
|
||||||
|
if (!proceed) return null
|
||||||
|
|
||||||
|
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
val intent = mgr.createScreenCaptureIntent()
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<CaptureResult?>()
|
||||||
|
pending = deferred
|
||||||
|
withContext(Dispatchers.Main) { launcher.launch(intent) }
|
||||||
|
|
||||||
|
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showRationaleDialog(): Boolean =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Screen recording required")
|
||||||
|
.setMessage("Clawdis needs to record the screen for this command.")
|
||||||
|
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||||
|
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||||
|
.setOnCancelListener { cont.resume(false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class SecurePrefs(context: Context) {
|
||||||
|
companion object {
|
||||||
|
val defaultWakeWords: List<String> = listOf("clawd", "claude")
|
||||||
|
private const val displayNameKey = "node.displayName"
|
||||||
|
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val masterKey =
|
||||||
|
MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val prefs =
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"clawdis.node.secure",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||||
|
val instanceId: StateFlow<String> = _instanceId
|
||||||
|
|
||||||
|
private val _displayName =
|
||||||
|
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||||
|
val displayName: StateFlow<String> = _displayName
|
||||||
|
|
||||||
|
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||||
|
|
||||||
|
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||||
|
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||||
|
|
||||||
|
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
||||||
|
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||||
|
|
||||||
|
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
||||||
|
val manualHost: StateFlow<String> = _manualHost
|
||||||
|
|
||||||
|
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
||||||
|
val manualPort: StateFlow<Int> = _manualPort
|
||||||
|
|
||||||
|
private val _lastDiscoveredStableId =
|
||||||
|
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||||
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
|
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||||
|
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||||
|
|
||||||
|
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||||
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||||
|
|
||||||
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||||
|
_lastDiscoveredStableId.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString(displayNameKey, trimmed).apply()
|
||||||
|
_displayName.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean("camera.enabled", value).apply()
|
||||||
|
_cameraEnabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPreventSleep(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean("screen.preventSleep", value).apply()
|
||||||
|
_preventSleep.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean("bridge.manual.enabled", value).apply()
|
||||||
|
_manualEnabled.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
prefs.edit().putString("bridge.manual.host", trimmed).apply()
|
||||||
|
_manualHost.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
prefs.edit().putInt("bridge.manual.port", value).apply()
|
||||||
|
_manualPort.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBridgeToken(): String? {
|
||||||
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
|
return prefs.getString(key, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBridgeToken(token: String) {
|
||||||
|
val key = "bridge.token.${_instanceId.value}"
|
||||||
|
prefs.edit().putString(key, token.trim()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadOrCreateInstanceId(): String {
|
||||||
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
val fresh = UUID.randomUUID().toString()
|
||||||
|
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||||
|
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||||
|
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||||
|
|
||||||
|
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||||
|
val resolved = candidate.ifEmpty { "Android Node" }
|
||||||
|
|
||||||
|
prefs.edit().putString(displayNameKey, resolved).apply()
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||||
|
val encoded =
|
||||||
|
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||||
|
prefs.edit().putString("voiceWake.triggerWords", encoded).apply()
|
||||||
|
_wakeWords.value = sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||||
|
prefs.edit().putString(voiceWakeModeKey, mode.rawValue).apply()
|
||||||
|
_voiceWakeMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||||
|
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||||
|
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||||
|
|
||||||
|
// Default ON (foreground) when unset.
|
||||||
|
if (raw.isNullOrBlank()) {
|
||||||
|
prefs.edit().putString(voiceWakeModeKey, resolved.rawValue).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadWakeWords(): List<String> {
|
||||||
|
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||||
|
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||||
|
return try {
|
||||||
|
val element = json.parseToJsonElement(raw)
|
||||||
|
val array = element as? JsonArray ?: return defaultWakeWords
|
||||||
|
val decoded =
|
||||||
|
array.mapNotNull { item ->
|
||||||
|
when (item) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WakeWords.sanitize(decoded, defaultWakeWords)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
defaultWakeWords
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
enum class VoiceWakeMode(val rawValue: String) {
|
||||||
|
Off("off"),
|
||||||
|
Foreground("foreground"),
|
||||||
|
Always("always"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromRawValue(raw: String?): VoiceWakeMode {
|
||||||
|
return entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
object WakeWords {
|
||||||
|
const val maxWords: Int = 32
|
||||||
|
const val maxWordLength: Int = 64
|
||||||
|
|
||||||
|
fun parseCommaSeparated(input: String): List<String> {
|
||||||
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
|
val cleaned =
|
||||||
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
|
return cleaned.ifEmpty { defaults }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
object BonjourEscapes {
|
||||||
|
fun decode(input: String): String {
|
||||||
|
if (input.isEmpty()) return input
|
||||||
|
|
||||||
|
val out = StringBuilder(input.length)
|
||||||
|
var i = 0
|
||||||
|
while (i < input.length) {
|
||||||
|
if (input[i] == '\\' && i + 3 < input.length) {
|
||||||
|
val d0 = input[i + 1]
|
||||||
|
val d1 = input[i + 2]
|
||||||
|
val d2 = input[i + 3]
|
||||||
|
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
|
||||||
|
val value =
|
||||||
|
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||||
|
if (value in 0..0x10FFFF) {
|
||||||
|
out.appendCodePoint(value)
|
||||||
|
i += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.append(input[i])
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.DnsResolver
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.CodingErrorAction
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.xbill.DNS.AAAARecord
|
||||||
|
import org.xbill.DNS.ARecord
|
||||||
|
import org.xbill.DNS.DClass
|
||||||
|
import org.xbill.DNS.ExtendedResolver
|
||||||
|
import org.xbill.DNS.Message
|
||||||
|
import org.xbill.DNS.Name
|
||||||
|
import org.xbill.DNS.PTRRecord
|
||||||
|
import org.xbill.DNS.Record
|
||||||
|
import org.xbill.DNS.Rcode
|
||||||
|
import org.xbill.DNS.Resolver
|
||||||
|
import org.xbill.DNS.SRVRecord
|
||||||
|
import org.xbill.DNS.Section
|
||||||
|
import org.xbill.DNS.SimpleResolver
|
||||||
|
import org.xbill.DNS.TextParseException
|
||||||
|
import org.xbill.DNS.TXTRecord
|
||||||
|
import org.xbill.DNS.Type
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class BridgeDiscovery(
|
||||||
|
context: Context,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
private val dns = DnsResolver.getInstance()
|
||||||
|
private val serviceType = "_clawdis-bridge._tcp."
|
||||||
|
private val wideAreaDomain = "clawdis.internal."
|
||||||
|
private val logTag = "Clawdis/BridgeDiscovery"
|
||||||
|
|
||||||
|
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
|
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
|
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Searching…")
|
||||||
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private var unicastJob: Job? = null
|
||||||
|
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||||
|
|
||||||
|
@Volatile private var lastWideAreaRcode: Int? = null
|
||||||
|
@Volatile private var lastWideAreaCount: Int = 0
|
||||||
|
|
||||||
|
private val discoveryListener =
|
||||||
|
object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||||
|
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
|
||||||
|
override fun onDiscoveryStarted(serviceType: String) {}
|
||||||
|
override fun onDiscoveryStopped(serviceType: String) {}
|
||||||
|
|
||||||
|
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||||
|
if (serviceInfo.serviceType != this@BridgeDiscovery.serviceType) return
|
||||||
|
resolve(serviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||||
|
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
|
||||||
|
val id = stableId(serviceName, "local.")
|
||||||
|
localById.remove(id)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
startLocalDiscovery()
|
||||||
|
startUnicastDiscovery(wideAreaDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocalDiscovery() {
|
||||||
|
try {
|
||||||
|
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore (best-effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLocalDiscovery() {
|
||||||
|
try {
|
||||||
|
nsd.stopServiceDiscovery(discoveryListener)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore (best-effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startUnicastDiscovery(domain: String) {
|
||||||
|
unicastJob =
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
refreshUnicast(domain)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore (best-effort)
|
||||||
|
}
|
||||||
|
delay(5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||||
|
nsd.resolveService(
|
||||||
|
serviceInfo,
|
||||||
|
object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
|
val host = resolved.host?.hostAddress ?: return
|
||||||
|
val port = resolved.port
|
||||||
|
if (port <= 0) return
|
||||||
|
|
||||||
|
val rawServiceName = resolved.serviceName
|
||||||
|
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||||
|
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||||
|
val id = stableId(serviceName, "local.")
|
||||||
|
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
_bridges.value =
|
||||||
|
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||||
|
_statusText.value = buildStatusText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStatusText(): String {
|
||||||
|
val localCount = localById.size
|
||||||
|
val wideRcode = lastWideAreaRcode
|
||||||
|
val wideCount = lastWideAreaCount
|
||||||
|
|
||||||
|
val wide =
|
||||||
|
when (wideRcode) {
|
||||||
|
null -> "Wide: ?"
|
||||||
|
Rcode.NOERROR -> "Wide: $wideCount"
|
||||||
|
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||||
|
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
localCount == 0 && wideRcode == null -> "Searching for bridges…"
|
||||||
|
localCount == 0 -> "$wide"
|
||||||
|
else -> "Local: $localCount • $wide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stableId(serviceName: String, domain: String): String {
|
||||||
|
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeName(raw: String): String {
|
||||||
|
return raw.trim().split(Regex("\\s+")).joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun txt(info: NsdServiceInfo, key: String): String? {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
|
||||||
|
val bytes = info.attributes[key] ?: return null
|
||||||
|
return try {
|
||||||
|
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
|
val ptrName = "${serviceType}${domain}"
|
||||||
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
|
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
|
||||||
|
|
||||||
|
val next = LinkedHashMap<String, BridgeEndpoint>()
|
||||||
|
for (ptr in ptrRecords) {
|
||||||
|
val instanceFqdn = ptr.target.toString()
|
||||||
|
val srv =
|
||||||
|
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||||
|
?: run {
|
||||||
|
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
|
||||||
|
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
|
||||||
|
}
|
||||||
|
?: continue
|
||||||
|
val port = srv.port
|
||||||
|
if (port <= 0) continue
|
||||||
|
|
||||||
|
val targetFqdn = srv.target.toString()
|
||||||
|
val host =
|
||||||
|
resolveHostFromMessage(ptrMsg, targetFqdn)
|
||||||
|
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
|
||||||
|
?: resolveHostUnicast(targetFqdn)
|
||||||
|
?: continue
|
||||||
|
|
||||||
|
val txtFromPtr =
|
||||||
|
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull { it as? TXTRecord }
|
||||||
|
val txt =
|
||||||
|
if (txtFromPtr.isNotEmpty()) {
|
||||||
|
txtFromPtr
|
||||||
|
} else {
|
||||||
|
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
|
||||||
|
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
|
||||||
|
}
|
||||||
|
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||||
|
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||||
|
val id = stableId(instanceName, domain)
|
||||||
|
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
|
}
|
||||||
|
|
||||||
|
unicastById.clear()
|
||||||
|
unicastById.putAll(next)
|
||||||
|
lastWideAreaRcode = ptrMsg.header.rcode
|
||||||
|
lastWideAreaCount = next.size
|
||||||
|
publish()
|
||||||
|
|
||||||
|
if (next.isEmpty()) {
|
||||||
|
Log.d(
|
||||||
|
logTag,
|
||||||
|
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeInstanceName(instanceFqdn: String, domain: String): String {
|
||||||
|
val suffix = "${serviceType}${domain}"
|
||||||
|
val withoutSuffix =
|
||||||
|
if (instanceFqdn.endsWith(suffix)) {
|
||||||
|
instanceFqdn.removeSuffix(suffix)
|
||||||
|
} else {
|
||||||
|
instanceFqdn.substringBefore(serviceType)
|
||||||
|
}
|
||||||
|
return normalizeName(stripTrailingDot(withoutSuffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripTrailingDot(raw: String): String {
|
||||||
|
return raw.removeSuffix(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun lookupUnicastMessage(name: String, type: Int): Message? {
|
||||||
|
val query =
|
||||||
|
try {
|
||||||
|
Message.newQuery(
|
||||||
|
org.xbill.DNS.Record.newRecord(
|
||||||
|
Name.fromString(name),
|
||||||
|
type,
|
||||||
|
DClass.IN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: TextParseException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val system = queryViaSystemDns(query)
|
||||||
|
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||||
|
|
||||||
|
val direct = createDirectResolver() ?: return system
|
||||||
|
return try {
|
||||||
|
val msg = direct.send(query)
|
||||||
|
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryViaSystemDns(query: Message): Message? {
|
||||||
|
val network = preferredDnsNetwork()
|
||||||
|
val bytes =
|
||||||
|
try {
|
||||||
|
rawQuery(network, query.toWire())
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
Message(bytes)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun records(msg: Message?, section: Int): List<Record> {
|
||||||
|
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyName(raw: String): String {
|
||||||
|
return raw.trim().lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordsByName(msg: Message, section: Int): Map<String, List<Record>> {
|
||||||
|
val next = LinkedHashMap<String, MutableList<Record>>()
|
||||||
|
for (r in records(msg, section)) {
|
||||||
|
val name = r.name?.toString() ?: continue
|
||||||
|
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordByName(msg: Message, fqdn: String, type: Int): Record? {
|
||||||
|
val key = keyName(fqdn)
|
||||||
|
val byNameAnswer = recordsByName(msg, Section.ANSWER)
|
||||||
|
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
|
||||||
|
if (fromAnswer != null) return fromAnswer
|
||||||
|
|
||||||
|
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
|
||||||
|
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveHostFromMessage(msg: Message?, hostname: String): String? {
|
||||||
|
val m = msg ?: return null
|
||||||
|
val key = keyName(hostname)
|
||||||
|
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
|
||||||
|
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
|
||||||
|
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
|
||||||
|
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferredDnsNetwork(): android.net.Network? {
|
||||||
|
val cm = connectivity ?: return null
|
||||||
|
|
||||||
|
// Prefer VPN (Tailscale) when present; otherwise use the active network.
|
||||||
|
cm.allNetworks.firstOrNull { n ->
|
||||||
|
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
return cm.activeNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDirectResolver(): Resolver? {
|
||||||
|
val cm = connectivity ?: return null
|
||||||
|
|
||||||
|
val candidateNetworks =
|
||||||
|
buildList {
|
||||||
|
cm.allNetworks
|
||||||
|
.firstOrNull { n ->
|
||||||
|
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}?.let(::add)
|
||||||
|
cm.activeNetwork?.let(::add)
|
||||||
|
}.distinct()
|
||||||
|
|
||||||
|
val servers =
|
||||||
|
candidateNetworks
|
||||||
|
.asSequence()
|
||||||
|
.flatMap { n ->
|
||||||
|
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
|
||||||
|
}
|
||||||
|
.distinctBy { it.hostAddress ?: it.toString() }
|
||||||
|
.toList()
|
||||||
|
if (servers.isEmpty()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val resolvers =
|
||||||
|
servers.mapNotNull { addr ->
|
||||||
|
try {
|
||||||
|
SimpleResolver().apply {
|
||||||
|
setAddress(InetSocketAddress(addr, 53))
|
||||||
|
setTimeout(3)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvers.isEmpty()) return null
|
||||||
|
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rawQuery(network: android.net.Network?, wireQuery: ByteArray): ByteArray =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val signal = CancellationSignal()
|
||||||
|
cont.invokeOnCancellation { signal.cancel() }
|
||||||
|
|
||||||
|
dns.rawQuery(
|
||||||
|
network,
|
||||||
|
wireQuery,
|
||||||
|
0,
|
||||||
|
dnsExecutor,
|
||||||
|
signal,
|
||||||
|
object : DnsResolver.Callback<ByteArray> {
|
||||||
|
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||||
|
cont.resume(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: DnsResolver.DnsException) {
|
||||||
|
cont.resumeWithException(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun txtValue(records: List<TXTRecord>, key: String): String? {
|
||||||
|
val prefix = "$key="
|
||||||
|
for (r in records) {
|
||||||
|
val strings: List<String> =
|
||||||
|
try {
|
||||||
|
r.strings.mapNotNull { it as? String }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
for (s in strings) {
|
||||||
|
val trimmed = decodeDnsTxtString(s).trim()
|
||||||
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
val decoder =
|
||||||
|
Charsets.UTF_8
|
||||||
|
.newDecoder()
|
||||||
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
|
return try {
|
||||||
|
decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveHostUnicast(hostname: String): String? {
|
||||||
|
val a =
|
||||||
|
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
|
||||||
|
.mapNotNull { it as? ARecord }
|
||||||
|
.mapNotNull { it.address?.hostAddress }
|
||||||
|
val aaaa =
|
||||||
|
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
|
||||||
|
.mapNotNull { it as? AAAARecord }
|
||||||
|
.mapNotNull { it.address?.hostAddress }
|
||||||
|
|
||||||
|
return a.firstOrNull() ?: aaaa.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
data class BridgeEndpoint(
|
||||||
|
val stableId: String,
|
||||||
|
val name: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
|
BridgeEndpoint(
|
||||||
|
stableId = "manual|$host|$port",
|
||||||
|
name = "$host:$port",
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
class BridgePairingClient {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
data class Hello(
|
||||||
|
val nodeId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val token: String?,
|
||||||
|
val platform: String?,
|
||||||
|
val version: String?,
|
||||||
|
val deviceFamily: String?,
|
||||||
|
val modelIdentifier: String?,
|
||||||
|
val caps: List<String>?,
|
||||||
|
val commands: List<String>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val nextLine = reader.readLine() ?: break
|
||||||
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
|
when (next["type"].asStringOrNull()) {
|
||||||
|
"pair-ok" -> {
|
||||||
|
val token = next["token"].asStringOrNull()
|
||||||
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
|
}
|
||||||
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class BridgeSession(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||||
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
|
) {
|
||||||
|
data class Hello(
|
||||||
|
val nodeId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val token: String?,
|
||||||
|
val platform: String?,
|
||||||
|
val version: String?,
|
||||||
|
val deviceFamily: String?,
|
||||||
|
val modelIdentifier: String?,
|
||||||
|
val caps: List<String>?,
|
||||||
|
val commands: List<String>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InvokeRequest(val id: String, val command: String, val paramsJson: String?)
|
||||||
|
|
||||||
|
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||||
|
companion object {
|
||||||
|
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||||
|
fun error(code: String, message: String) =
|
||||||
|
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ErrorShape(val code: String, val message: String)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val writeLock = Mutex()
|
||||||
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
|
|
||||||
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
|
desired = endpoint to hello
|
||||||
|
if (job == null) {
|
||||||
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
desired = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
job?.cancelAndJoin()
|
||||||
|
job = null
|
||||||
|
onDisconnected("Offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||||
|
val conn = currentConnection ?: return
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("event"))
|
||||||
|
put("event", JsonPrimitive(event))
|
||||||
|
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun request(method: String, paramsJson: String?): String {
|
||||||
|
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
val deferred = CompletableDeferred<RpcResponse>()
|
||||||
|
pending[id] = deferred
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("req"))
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("method", JsonPrimitive(method))
|
||||||
|
if (paramsJson != null) put("paramsJSON", JsonPrimitive(paramsJson)) else put("paramsJSON", JsonNull)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val res = deferred.await()
|
||||||
|
if (res.ok) return res.payloadJson ?: ""
|
||||||
|
val err = res.error
|
||||||
|
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||||
|
|
||||||
|
private class Connection(private val socket: Socket, private val reader: BufferedReader, private val writer: BufferedWriter, private val writeLock: Mutex) {
|
||||||
|
val remoteAddress: String? =
|
||||||
|
socket.inetAddress?.hostAddress?.takeIf { it.isNotBlank() }?.let { "${it}:${socket.port}" }
|
||||||
|
|
||||||
|
suspend fun sendJson(obj: JsonObject) {
|
||||||
|
writeLock.withLock {
|
||||||
|
writer.write(obj.toString())
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeQuietly() {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var currentConnection: Connection? = null
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
while (scope.isActive) {
|
||||||
|
val target = desired
|
||||||
|
if (target == null) {
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
|
currentConnection = null
|
||||||
|
delay(250)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val (endpoint, hello) = target
|
||||||
|
try {
|
||||||
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
|
connectOnce(endpoint, hello)
|
||||||
|
attempt = 0
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
attempt += 1
|
||||||
|
onDisconnected("Bridge error: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||||
|
delay(sleepMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||||
|
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||||
|
val parts = msg.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val code = parts[0].trim()
|
||||||
|
val rest = parts[1].trim()
|
||||||
|
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||||
|
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 0
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
|
currentConnection = conn
|
||||||
|
|
||||||
|
try {
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
|
when (first["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> {
|
||||||
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
|
onConnected(name, conn.remoteAddress)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
|
}
|
||||||
|
|
||||||
|
while (scope.isActive) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
|
when (frame["type"].asStringOrNull()) {
|
||||||
|
"event" -> {
|
||||||
|
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||||
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
|
onEvent(event, payload)
|
||||||
|
}
|
||||||
|
"ping" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
|
}
|
||||||
|
"res" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: continue
|
||||||
|
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||||
|
val payloadJson = frame["payloadJSON"].asStringOrNull()
|
||||||
|
val error =
|
||||||
|
frame["error"]?.let {
|
||||||
|
val obj = it.asObjectOrNull() ?: return@let null
|
||||||
|
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||||
|
ErrorShape(code, msg)
|
||||||
|
}
|
||||||
|
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||||
|
}
|
||||||
|
"invoke" -> {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: continue
|
||||||
|
val command = frame["command"].asStringOrNull() ?: ""
|
||||||
|
val params = frame["paramsJSON"].asStringOrNull()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
onInvoke(InvokeRequest(id, command, params))
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
invokeErrorFromThrowable(err)
|
||||||
|
}
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("invoke-res"))
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("ok", JsonPrimitive(result.ok))
|
||||||
|
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||||
|
if (result.error != null) {
|
||||||
|
put(
|
||||||
|
"error",
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", JsonPrimitive(result.error.code))
|
||||||
|
put("message", JsonPrimitive(result.error.message))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"invoke-res" -> {
|
||||||
|
// gateway->node only (ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
val c = content.trim()
|
||||||
|
when {
|
||||||
|
c.equals("true", ignoreCase = true) -> true
|
||||||
|
c.equals("false", ignoreCase = true) -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package com.steipete.clawdis.node.chat
|
||||||
|
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
|
||||||
|
class ChatController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val session: BridgeSession,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
private val _sessionKey = MutableStateFlow("main")
|
||||||
|
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||||
|
|
||||||
|
private val _sessionId = MutableStateFlow<String?>(null)
|
||||||
|
val sessionId: StateFlow<String?> = _sessionId.asStateFlow()
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorText = MutableStateFlow<String?>(null)
|
||||||
|
val errorText: StateFlow<String?> = _errorText.asStateFlow()
|
||||||
|
|
||||||
|
private val _healthOk = MutableStateFlow(false)
|
||||||
|
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
|
||||||
|
|
||||||
|
private val _thinkingLevel = MutableStateFlow("off")
|
||||||
|
val thinkingLevel: StateFlow<String> = _thinkingLevel.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingRunCount = MutableStateFlow(0)
|
||||||
|
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||||
|
|
||||||
|
private val _streamingAssistantText = MutableStateFlow<String?>(null)
|
||||||
|
val streamingAssistantText: StateFlow<String?> = _streamingAssistantText.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingToolCallsById = ConcurrentHashMap<String, ChatPendingToolCall>()
|
||||||
|
private val _pendingToolCalls = MutableStateFlow<List<ChatPendingToolCall>>(emptyList())
|
||||||
|
val pendingToolCalls: StateFlow<List<ChatPendingToolCall>> = _pendingToolCalls.asStateFlow()
|
||||||
|
|
||||||
|
private val _sessions = MutableStateFlow<List<ChatSessionEntry>>(emptyList())
|
||||||
|
val sessions: StateFlow<List<ChatSessionEntry>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingRuns = mutableSetOf<String>()
|
||||||
|
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||||
|
private val pendingRunTimeoutMs = 120_000L
|
||||||
|
|
||||||
|
private var lastHealthPollAtMs: Long? = null
|
||||||
|
|
||||||
|
fun onDisconnected(message: String) {
|
||||||
|
_healthOk.value = false
|
||||||
|
// Not an error; keep connection status in the UI pill.
|
||||||
|
_errorText.value = null
|
||||||
|
clearPendingRuns()
|
||||||
|
pendingToolCallsById.clear()
|
||||||
|
publishPendingToolCalls()
|
||||||
|
_streamingAssistantText.value = null
|
||||||
|
_sessionId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(sessionKey: String = "main") {
|
||||||
|
val key = sessionKey.trim().ifEmpty { "main" }
|
||||||
|
_sessionKey.value = key
|
||||||
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSessions(limit: Int? = null) {
|
||||||
|
scope.launch { fetchSessions(limit = limit) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setThinkingLevel(thinkingLevel: String) {
|
||||||
|
val normalized = normalizeThinking(thinkingLevel)
|
||||||
|
if (normalized == _thinkingLevel.value) return
|
||||||
|
_thinkingLevel.value = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchSession(sessionKey: String) {
|
||||||
|
val key = sessionKey.trim()
|
||||||
|
if (key.isEmpty()) return
|
||||||
|
if (key == _sessionKey.value) return
|
||||||
|
_sessionKey.value = key
|
||||||
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(
|
||||||
|
message: String,
|
||||||
|
thinkingLevel: String,
|
||||||
|
attachments: List<OutgoingAttachment>,
|
||||||
|
) {
|
||||||
|
val trimmed = message.trim()
|
||||||
|
if (trimmed.isEmpty() && attachments.isEmpty()) return
|
||||||
|
if (!_healthOk.value) {
|
||||||
|
_errorText.value = "Gateway health not OK; cannot send"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val runId = UUID.randomUUID().toString()
|
||||||
|
val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed
|
||||||
|
val sessionKey = _sessionKey.value
|
||||||
|
val thinking = normalizeThinking(thinkingLevel)
|
||||||
|
|
||||||
|
// Optimistic user message.
|
||||||
|
val userContent =
|
||||||
|
buildList {
|
||||||
|
add(ChatMessageContent(type = "text", text = text))
|
||||||
|
for (att in attachments) {
|
||||||
|
add(
|
||||||
|
ChatMessageContent(
|
||||||
|
type = att.type,
|
||||||
|
mimeType = att.mimeType,
|
||||||
|
fileName = att.fileName,
|
||||||
|
base64 = att.base64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_messages.value =
|
||||||
|
_messages.value +
|
||||||
|
ChatMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
role = "user",
|
||||||
|
content = userContent,
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
armPendingRunTimeout(runId)
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.add(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
}
|
||||||
|
|
||||||
|
_errorText.value = null
|
||||||
|
_streamingAssistantText.value = null
|
||||||
|
pendingToolCallsById.clear()
|
||||||
|
publishPendingToolCalls()
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("sessionKey", JsonPrimitive(sessionKey))
|
||||||
|
put("message", JsonPrimitive(text))
|
||||||
|
put("thinking", JsonPrimitive(thinking))
|
||||||
|
put("timeoutMs", JsonPrimitive(30_000))
|
||||||
|
put("idempotencyKey", JsonPrimitive(runId))
|
||||||
|
if (attachments.isNotEmpty()) {
|
||||||
|
put(
|
||||||
|
"attachments",
|
||||||
|
JsonArray(
|
||||||
|
attachments.map { att ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive(att.type))
|
||||||
|
put("mimeType", JsonPrimitive(att.mimeType))
|
||||||
|
put("fileName", JsonPrimitive(att.fileName))
|
||||||
|
put("content", JsonPrimitive(att.base64))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val res = session.request("chat.send", params.toString())
|
||||||
|
val actualRunId = parseRunId(res) ?: runId
|
||||||
|
if (actualRunId != runId) {
|
||||||
|
clearPendingRun(runId)
|
||||||
|
armPendingRunTimeout(actualRunId)
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.add(actualRunId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
clearPendingRun(runId)
|
||||||
|
_errorText.value = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abort() {
|
||||||
|
val runIds =
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.toList()
|
||||||
|
}
|
||||||
|
if (runIds.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
for (runId in runIds) {
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("sessionKey", JsonPrimitive(_sessionKey.value))
|
||||||
|
put("runId", JsonPrimitive(runId))
|
||||||
|
}
|
||||||
|
session.request("chat.abort", params.toString())
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
|
when (event) {
|
||||||
|
"tick" -> {
|
||||||
|
scope.launch { pollHealthIfNeeded(force = false) }
|
||||||
|
}
|
||||||
|
"health" -> {
|
||||||
|
// If we receive a health snapshot, the gateway is reachable.
|
||||||
|
_healthOk.value = true
|
||||||
|
}
|
||||||
|
"seqGap" -> {
|
||||||
|
_errorText.value = "Event stream interrupted; try refreshing."
|
||||||
|
clearPendingRuns()
|
||||||
|
}
|
||||||
|
"chat" -> {
|
||||||
|
if (payloadJson.isNullOrBlank()) return
|
||||||
|
handleChatEvent(payloadJson)
|
||||||
|
}
|
||||||
|
"agent" -> {
|
||||||
|
if (payloadJson.isNullOrBlank()) return
|
||||||
|
handleAgentEvent(payloadJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun bootstrap(forceHealth: Boolean) {
|
||||||
|
_errorText.value = null
|
||||||
|
_healthOk.value = false
|
||||||
|
clearPendingRuns()
|
||||||
|
pendingToolCallsById.clear()
|
||||||
|
publishPendingToolCalls()
|
||||||
|
_streamingAssistantText.value = null
|
||||||
|
_sessionId.value = null
|
||||||
|
|
||||||
|
val key = _sessionKey.value
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||||
|
val history = parseHistory(historyJson, sessionKey = key)
|
||||||
|
_messages.value = history.messages
|
||||||
|
_sessionId.value = history.sessionId
|
||||||
|
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||||
|
|
||||||
|
pollHealthIfNeeded(force = forceHealth)
|
||||||
|
fetchSessions(limit = 50)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
_errorText.value = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchSessions(limit: Int?) {
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("includeGlobal", JsonPrimitive(true))
|
||||||
|
put("includeUnknown", JsonPrimitive(false))
|
||||||
|
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
|
||||||
|
}
|
||||||
|
val res = session.request("sessions.list", params.toString())
|
||||||
|
_sessions.value = parseSessions(res)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollHealthIfNeeded(force: Boolean) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val last = lastHealthPollAtMs
|
||||||
|
if (!force && last != null && now - last < 10_000) return
|
||||||
|
lastHealthPollAtMs = now
|
||||||
|
try {
|
||||||
|
session.request("health", null)
|
||||||
|
_healthOk.value = true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
_healthOk.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleChatEvent(payloadJson: String) {
|
||||||
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
|
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
|
||||||
|
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 state = payload["state"].asStringOrNull()
|
||||||
|
when (state) {
|
||||||
|
"final", "aborted", "error" -> {
|
||||||
|
if (state == "error") {
|
||||||
|
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||||
|
}
|
||||||
|
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||||
|
pendingToolCallsById.clear()
|
||||||
|
publishPendingToolCalls()
|
||||||
|
_streamingAssistantText.value = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val historyJson =
|
||||||
|
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||||
|
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
||||||
|
_messages.value = history.messages
|
||||||
|
_sessionId.value = history.sessionId
|
||||||
|
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAgentEvent(payloadJson: String) {
|
||||||
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
|
val runId = payload["runId"].asStringOrNull()
|
||||||
|
val sessionId = _sessionId.value
|
||||||
|
if (sessionId != null && runId != sessionId) return
|
||||||
|
|
||||||
|
val stream = payload["stream"].asStringOrNull()
|
||||||
|
val data = payload["data"].asObjectOrNull()
|
||||||
|
|
||||||
|
when (stream) {
|
||||||
|
"assistant" -> {
|
||||||
|
val text = data?.get("text")?.asStringOrNull()
|
||||||
|
if (!text.isNullOrEmpty()) {
|
||||||
|
_streamingAssistantText.value = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool" -> {
|
||||||
|
val phase = data?.get("phase")?.asStringOrNull()
|
||||||
|
val name = data?.get("name")?.asStringOrNull()
|
||||||
|
val toolCallId = data?.get("toolCallId")?.asStringOrNull()
|
||||||
|
if (phase.isNullOrEmpty() || name.isNullOrEmpty() || toolCallId.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
||||||
|
if (phase == "start") {
|
||||||
|
pendingToolCallsById[toolCallId] =
|
||||||
|
ChatPendingToolCall(
|
||||||
|
toolCallId = toolCallId,
|
||||||
|
name = name,
|
||||||
|
startedAtMs = ts,
|
||||||
|
isError = null,
|
||||||
|
)
|
||||||
|
publishPendingToolCalls()
|
||||||
|
} else if (phase == "result") {
|
||||||
|
pendingToolCallsById.remove(toolCallId)
|
||||||
|
publishPendingToolCalls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
_errorText.value = "Event stream interrupted; try refreshing."
|
||||||
|
clearPendingRuns()
|
||||||
|
pendingToolCallsById.clear()
|
||||||
|
publishPendingToolCalls()
|
||||||
|
_streamingAssistantText.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishPendingToolCalls() {
|
||||||
|
_pendingToolCalls.value =
|
||||||
|
pendingToolCallsById.values.sortedBy { it.startedAtMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armPendingRunTimeout(runId: String) {
|
||||||
|
pendingRunTimeoutJobs[runId]?.cancel()
|
||||||
|
pendingRunTimeoutJobs[runId] =
|
||||||
|
scope.launch {
|
||||||
|
delay(pendingRunTimeoutMs)
|
||||||
|
val stillPending =
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.contains(runId)
|
||||||
|
}
|
||||||
|
if (!stillPending) return@launch
|
||||||
|
clearPendingRun(runId)
|
||||||
|
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingRun(runId: String) {
|
||||||
|
pendingRunTimeoutJobs.remove(runId)?.cancel()
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.remove(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPendingRuns() {
|
||||||
|
for ((_, job) in pendingRunTimeoutJobs) {
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
pendingRunTimeoutJobs.clear()
|
||||||
|
synchronized(pendingRuns) {
|
||||||
|
pendingRuns.clear()
|
||||||
|
_pendingRunCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
||||||
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||||
|
val sid = root["sessionId"].asStringOrNull()
|
||||||
|
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||||
|
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
|
||||||
|
|
||||||
|
val messages =
|
||||||
|
array.mapNotNull { item ->
|
||||||
|
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||||
|
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||||
|
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseMessageContent) ?: emptyList()
|
||||||
|
val ts = obj["timestamp"].asLongOrNull()
|
||||||
|
ChatMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
timestampMs = ts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||||
|
val obj = el.asObjectOrNull() ?: return null
|
||||||
|
val type = obj["type"].asStringOrNull() ?: "text"
|
||||||
|
return if (type == "text") {
|
||||||
|
ChatMessageContent(type = "text", text = obj["text"].asStringOrNull())
|
||||||
|
} else {
|
||||||
|
ChatMessageContent(
|
||||||
|
type = type,
|
||||||
|
mimeType = obj["mimeType"].asStringOrNull(),
|
||||||
|
fileName = obj["fileName"].asStringOrNull(),
|
||||||
|
base64 = obj["content"].asStringOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
|
||||||
|
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
|
||||||
|
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
|
||||||
|
return sessions.mapNotNull { item ->
|
||||||
|
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||||
|
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||||
|
if (key.isEmpty()) return@mapNotNull null
|
||||||
|
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||||
|
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRunId(resJson: String): String? {
|
||||||
|
return try {
|
||||||
|
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeThinking(raw: String): String {
|
||||||
|
return when (raw.trim().lowercase()) {
|
||||||
|
"low" -> "low"
|
||||||
|
"medium" -> "medium"
|
||||||
|
"high" -> "high"
|
||||||
|
else -> "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asLongOrNull(): Long? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> content.toLongOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.steipete.clawdis.node.chat
|
||||||
|
|
||||||
|
data class ChatMessage(
|
||||||
|
val id: String,
|
||||||
|
val role: String,
|
||||||
|
val content: List<ChatMessageContent>,
|
||||||
|
val timestampMs: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatMessageContent(
|
||||||
|
val type: String = "text",
|
||||||
|
val text: String? = null,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val fileName: String? = null,
|
||||||
|
val base64: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatPendingToolCall(
|
||||||
|
val toolCallId: String,
|
||||||
|
val name: String,
|
||||||
|
val startedAtMs: Long,
|
||||||
|
val isError: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatSessionEntry(
|
||||||
|
val key: String,
|
||||||
|
val updatedAtMs: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatHistory(
|
||||||
|
val sessionKey: String,
|
||||||
|
val sessionId: String?,
|
||||||
|
val thinkingLevel: String?,
|
||||||
|
val messages: List<ChatMessage>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OutgoingAttachment(
|
||||||
|
val type: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val fileName: String,
|
||||||
|
val base64: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.video.FileOutputOptions
|
||||||
|
import androidx.camera.video.Recorder
|
||||||
|
import androidx.camera.video.Recording
|
||||||
|
import androidx.camera.video.VideoCapture
|
||||||
|
import androidx.camera.video.VideoRecordEvent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||||
|
import com.steipete.clawdis.node.PermissionRequester
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class CameraCaptureManager(private val context: Context) {
|
||||||
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
|
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||||
|
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||||
|
|
||||||
|
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||||
|
lifecycleOwner = owner
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||||
|
permissionRequester = requester
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureCameraPermission() {
|
||||||
|
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return
|
||||||
|
|
||||||
|
val requester = permissionRequester
|
||||||
|
?: throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.CAMERA))
|
||||||
|
if (results[Manifest.permission.CAMERA] != true) {
|
||||||
|
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureMicPermission() {
|
||||||
|
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return
|
||||||
|
|
||||||
|
val requester = permissionRequester
|
||||||
|
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
val results = requester.requestIfMissing(listOf(Manifest.permission.RECORD_AUDIO))
|
||||||
|
if (results[Manifest.permission.RECORD_AUDIO] != true) {
|
||||||
|
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snap(paramsJson: String?): Payload =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
ensureCameraPermission()
|
||||||
|
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||||
|
val facing = parseFacing(paramsJson) ?: "front"
|
||||||
|
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||||
|
val maxWidth = parseMaxWidth(paramsJson)
|
||||||
|
|
||||||
|
val provider = context.cameraProvider()
|
||||||
|
val capture = ImageCapture.Builder().build()
|
||||||
|
val selector =
|
||||||
|
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(owner, selector, capture)
|
||||||
|
|
||||||
|
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||||
|
val h =
|
||||||
|
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(decoded, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
||||||
|
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
||||||
|
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||||
|
}
|
||||||
|
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
Payload(
|
||||||
|
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clip(paramsJson: String?): Payload =
|
||||||
|
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
|
||||||
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
|
val provider = context.cameraProvider()
|
||||||
|
val recorder = Recorder.Builder().build()
|
||||||
|
val videoCapture = VideoCapture.withOutput(recorder)
|
||||||
|
val selector =
|
||||||
|
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||||
|
|
||||||
|
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||||
|
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||||
|
|
||||||
|
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||||
|
val recording: Recording =
|
||||||
|
videoCapture.output
|
||||||
|
.prepareRecording(context, outputOptions)
|
||||||
|
.apply {
|
||||||
|
if (includeAudio) withAudioEnabled()
|
||||||
|
}
|
||||||
|
.start(context.mainExecutor()) { event ->
|
||||||
|
if (event is VideoRecordEvent.Finalize) {
|
||||||
|
finalized.complete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
kotlinx.coroutines.delay(durationMs.toLong())
|
||||||
|
} finally {
|
||||||
|
recording.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
val finalizeEvent =
|
||||||
|
try {
|
||||||
|
withTimeout(10_000) { finalized.await() }
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
file.delete()
|
||||||
|
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||||
|
}
|
||||||
|
if (finalizeEvent.hasError()) {
|
||||||
|
file.delete()
|
||||||
|
throw IllegalStateException("UNAVAILABLE: camera clip failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
file.delete()
|
||||||
|
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
Payload(
|
||||||
|
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFacing(paramsJson: String?): String? =
|
||||||
|
when {
|
||||||
|
paramsJson?.contains("\"front\"") == true -> "front"
|
||||||
|
paramsJson?.contains("\"back\"") == true -> "back"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuality(paramsJson: String?): Double? =
|
||||||
|
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||||
|
|
||||||
|
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
|
||||||
|
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 Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val future = ProcessCameraProvider.getInstance(this)
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
cont.resume(future.get())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ContextCompat.getMainExecutor(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||||
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
|
takePicture(
|
||||||
|
options,
|
||||||
|
executor,
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
cont.resumeWithException(exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
|
try {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
cont.resume(bytes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.WebView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import android.util.Base64
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class CanvasController {
|
||||||
|
enum class SnapshotFormat(val rawValue: String) {
|
||||||
|
Png("png"),
|
||||||
|
Jpeg("jpeg"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var webView: WebView? = null
|
||||||
|
@Volatile private var url: String? = null
|
||||||
|
|
||||||
|
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||||
|
|
||||||
|
private fun clampJpegQuality(quality: Double?): Int {
|
||||||
|
val q = (quality ?: 0.82).coerceIn(0.1, 1.0)
|
||||||
|
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attach(webView: WebView) {
|
||||||
|
this.webView = webView
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigate(url: String) {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||||
|
val wv = webView ?: return
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
block(wv)
|
||||||
|
} else {
|
||||||
|
wv.post { block(wv) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
val currentUrl = url
|
||||||
|
withWebViewOnMain { wv ->
|
||||||
|
if (currentUrl == null) {
|
||||||
|
wv.loadUrl(scaffoldAssetUrl)
|
||||||
|
} else {
|
||||||
|
wv.loadUrl(currentUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun eval(javaScript: String): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
wv.evaluateJavascript(javaScript) { result ->
|
||||||
|
cont.resume(result ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snapshotPngBase64(maxWidth: Int?): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
val bmp = wv.captureBitmap()
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||||
|
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val wv = webView ?: throw IllegalStateException("no webview")
|
||||||
|
val bmp = wv.captureBitmap()
|
||||||
|
val scaled =
|
||||||
|
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||||
|
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||||
|
Bitmap.createScaledBitmap(bmp, maxWidth, h, true)
|
||||||
|
} else {
|
||||||
|
bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val (compressFormat, compressQuality) =
|
||||||
|
when (format) {
|
||||||
|
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||||
|
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||||
|
}
|
||||||
|
scaled.compress(compressFormat, compressQuality, out)
|
||||||
|
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val width = width.coerceAtLeast(1)
|
||||||
|
val height = height.coerceAtLeast(1)
|
||||||
|
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
|
// WebView isn't supported by PixelCopy.request(...) directly; draw() is the most reliable
|
||||||
|
// cross-version snapshot for this lightweight "canvas" use-case.
|
||||||
|
draw(Canvas(bitmap))
|
||||||
|
cont.resume(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
data class SnapshotParams(val format: SnapshotFormat, val quality: Double?, val maxWidth: Int?)
|
||||||
|
|
||||||
|
fun parseNavigateUrl(paramsJson: String?): String {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return ""
|
||||||
|
return obj.string("url").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseEvalJs(paramsJson: String?): String? {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
|
val js = obj.string("javaScript").trim()
|
||||||
|
return js.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotMaxWidth(paramsJson: String?): Int? {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
|
if (!obj.containsKey("maxWidth")) return null
|
||||||
|
val width = obj.int("maxWidth") ?: 0
|
||||||
|
return width.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotFormat(paramsJson: String?): SnapshotFormat {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return SnapshotFormat.Jpeg
|
||||||
|
val raw = obj.string("format").trim().lowercase()
|
||||||
|
return when (raw) {
|
||||||
|
"png" -> SnapshotFormat.Png
|
||||||
|
"jpeg", "jpg" -> SnapshotFormat.Jpeg
|
||||||
|
"" -> SnapshotFormat.Jpeg
|
||||||
|
else -> SnapshotFormat.Jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotQuality(paramsJson: String?): Double? {
|
||||||
|
val obj = parseParamsObject(paramsJson) ?: return null
|
||||||
|
if (!obj.containsKey("quality")) return null
|
||||||
|
val q = obj.double("quality") ?: Double.NaN
|
||||||
|
if (!q.isFinite()) return null
|
||||||
|
return q.coerceIn(0.1, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseSnapshotParams(paramsJson: String?): SnapshotParams {
|
||||||
|
return SnapshotParams(
|
||||||
|
format = parseSnapshotFormat(paramsJson),
|
||||||
|
quality = parseSnapshotQuality(paramsJson),
|
||||||
|
maxWidth = parseSnapshotMaxWidth(paramsJson),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||||
|
val raw = paramsJson?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
return try {
|
||||||
|
json.parseToJsonElement(raw).asObjectOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String): String {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return ""
|
||||||
|
val raw = prim.content
|
||||||
|
return raw.takeIf { it != "null" }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.int(key: String): Int? {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return null
|
||||||
|
return prim.content.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.double(key: String): Double? {
|
||||||
|
val prim = this[key] as? JsonPrimitive ?: return null
|
||||||
|
return prim.content.toDoubleOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.util.Base64
|
||||||
|
import com.steipete.clawdis.node.ScreenCaptureRequester
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class ScreenRecordManager(private val context: Context) {
|
||||||
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
|
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||||
|
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
|
||||||
|
|
||||||
|
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||||
|
screenCaptureRequester = requester
|
||||||
|
}
|
||||||
|
|
||||||
|
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
|
||||||
|
permissionRequester = requester
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun record(paramsJson: String?): Payload =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val requester =
|
||||||
|
screenCaptureRequester
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"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 fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||||
|
val screenIndex = parseScreenIndex(paramsJson)
|
||||||
|
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||||
|
val format = parseString(paramsJson, key = "format")
|
||||||
|
if (format != null && format.lowercase() != "mp4") {
|
||||||
|
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||||
|
}
|
||||||
|
if (screenIndex != null && screenIndex != 0) {
|
||||||
|
throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
val capture = requester.requestCapture()
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||||
|
)
|
||||||
|
|
||||||
|
val mgr =
|
||||||
|
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
|
||||||
|
?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
|
||||||
|
|
||||||
|
val metrics = context.resources.displayMetrics
|
||||||
|
val width = metrics.widthPixels
|
||||||
|
val height = metrics.heightPixels
|
||||||
|
val densityDpi = metrics.densityDpi
|
||||||
|
|
||||||
|
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
||||||
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
|
val recorder = MediaRecorder()
|
||||||
|
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||||
|
try {
|
||||||
|
if (includeAudio) {
|
||||||
|
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
}
|
||||||
|
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
||||||
|
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
||||||
|
if (includeAudio) {
|
||||||
|
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
recorder.setAudioChannels(1)
|
||||||
|
recorder.setAudioSamplingRate(44_100)
|
||||||
|
recorder.setAudioEncodingBitRate(96_000)
|
||||||
|
}
|
||||||
|
recorder.setVideoSize(width, height)
|
||||||
|
recorder.setVideoFrameRate(fpsInt)
|
||||||
|
recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
|
||||||
|
recorder.setOutputFile(file.absolutePath)
|
||||||
|
recorder.prepare()
|
||||||
|
|
||||||
|
val surface = recorder.surface
|
||||||
|
virtualDisplay =
|
||||||
|
projection.createVirtualDisplay(
|
||||||
|
"clawdis-screen",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
densityDpi,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
|
surface,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
recorder.start()
|
||||||
|
delay(durationMs.toLong())
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
recorder.stop()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
recorder.reset()
|
||||||
|
recorder.release()
|
||||||
|
virtualDisplay?.release()
|
||||||
|
projection.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = withContext(Dispatchers.IO) { file.readBytes() }
|
||||||
|
file.delete()
|
||||||
|
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
Payload(
|
||||||
|
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureMicPermission() {
|
||||||
|
val granted =
|
||||||
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
android.Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) return
|
||||||
|
|
||||||
|
val requester =
|
||||||
|
permissionRequester
|
||||||
|
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
|
||||||
|
if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
|
||||||
|
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||||
|
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||||
|
|
||||||
|
private fun parseFps(paramsJson: String?): Double? =
|
||||||
|
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||||
|
|
||||||
|
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||||
|
parseNumber(paramsJson, key = "screenIndex")?.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
|
||||||
|
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 estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||||
|
val pixels = width.toLong() * height.toLong()
|
||||||
|
val raw = (pixels * fps.toLong() * 2L).toInt()
|
||||||
|
return raw.coerceIn(1_000_000, 12_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
object ClawdisCanvasA2UIAction {
|
||||||
|
fun sanitizeTagValue(value: String): String {
|
||||||
|
val trimmed = value.trim().ifEmpty { "-" }
|
||||||
|
val normalized = trimmed.replace(" ", "_")
|
||||||
|
val out = StringBuilder(normalized.length)
|
||||||
|
for (c in normalized) {
|
||||||
|
val ok =
|
||||||
|
c.isLetterOrDigit() ||
|
||||||
|
c == '_' ||
|
||||||
|
c == '-' ||
|
||||||
|
c == '.' ||
|
||||||
|
c == ':'
|
||||||
|
out.append(if (ok) c else '_')
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatAgentMessage(
|
||||||
|
actionName: String,
|
||||||
|
sessionKey: String,
|
||||||
|
surfaceId: String,
|
||||||
|
sourceComponentId: String,
|
||||||
|
host: String,
|
||||||
|
instanceId: String,
|
||||||
|
contextJson: String?,
|
||||||
|
): String {
|
||||||
|
val ctxSuffix = contextJson?.takeIf { it.isNotBlank() }?.let { " ctx=$it" }.orEmpty()
|
||||||
|
return listOf(
|
||||||
|
"CANVAS_A2UI",
|
||||||
|
"action=${sanitizeTagValue(actionName)}",
|
||||||
|
"session=${sanitizeTagValue(sessionKey)}",
|
||||||
|
"surface=${sanitizeTagValue(surfaceId)}",
|
||||||
|
"component=${sanitizeTagValue(sourceComponentId)}",
|
||||||
|
"host=${sanitizeTagValue(host)}",
|
||||||
|
"instance=${sanitizeTagValue(instanceId)}$ctxSuffix",
|
||||||
|
"default=update_canvas",
|
||||||
|
).joinToString(separator = " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||||
|
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
val okLiteral = if (ok) "true" else "false"
|
||||||
|
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
|
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
enum class ClawdisCapability(val rawValue: String) {
|
||||||
|
Canvas("canvas"),
|
||||||
|
Camera("camera"),
|
||||||
|
Screen("screen"),
|
||||||
|
VoiceWake("voiceWake"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||||
|
Present("canvas.present"),
|
||||||
|
Hide("canvas.hide"),
|
||||||
|
Navigate("canvas.navigate"),
|
||||||
|
Eval("canvas.eval"),
|
||||||
|
Snapshot("canvas.snapshot"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NamespacePrefix: String = "canvas."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
||||||
|
Push("canvas.a2ui.push"),
|
||||||
|
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||||
|
Reset("canvas.a2ui.reset"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NamespacePrefix: String = "canvas.a2ui."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ClawdisCameraCommand(val rawValue: String) {
|
||||||
|
Snap("camera.snap"),
|
||||||
|
Clip("camera.clip"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NamespacePrefix: String = "camera."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ClawdisScreenCommand(val rawValue: String) {
|
||||||
|
Record("screen.record"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NamespacePrefix: String = "screen."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.CameraHudKind
|
||||||
|
import com.steipete.clawdis.node.CameraHudState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraHudOverlay(
|
||||||
|
hud: CameraHudState?,
|
||||||
|
flashToken: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
CameraFlash(token = flashToken)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = hud != null,
|
||||||
|
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
|
||||||
|
) {
|
||||||
|
if (hud != null) {
|
||||||
|
Toast(hud = hud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraFlash(token: Long) {
|
||||||
|
var alpha by remember { mutableFloatStateOf(0f) }
|
||||||
|
LaunchedEffect(token) {
|
||||||
|
if (token == 0L) return@LaunchedEffect
|
||||||
|
alpha = 0.85f
|
||||||
|
delay(110)
|
||||||
|
alpha = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Toast(hud: CameraHudState) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when (hud.kind) {
|
||||||
|
CameraHudKind.Photo -> {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
CameraHudKind.Recording -> {
|
||||||
|
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
|
||||||
|
}
|
||||||
|
CameraHudKind.Success -> {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||||
|
}
|
||||||
|
CameraHudKind.Error -> {
|
||||||
|
Icon(Icons.Default.Error, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
Text(
|
||||||
|
text = hud.message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatSheet(viewModel: MainViewModel) {
|
||||||
|
ChatSheetContent(viewModel = viewModel)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClawdisTheme(content: @Composable () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isDark = isSystemInDarkTheme()
|
||||||
|
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
|
||||||
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun overlayContainerColor(): Color {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val isDark = isSystemInDarkTheme()
|
||||||
|
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||||
|
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
|
||||||
|
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun overlayIconColor(): Color {
|
||||||
|
return MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RootScreen(viewModel: MainViewModel) {
|
||||||
|
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||||
|
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||||
|
|
||||||
|
val bridgeState =
|
||||||
|
remember(serverName, statusText) {
|
||||||
|
when {
|
||||||
|
serverName != null -> BridgeState.Connected
|
||||||
|
statusText.contains("connecting", ignoreCase = true) ||
|
||||||
|
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||||
|
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||||
|
else -> BridgeState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceEnabled =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
||||||
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
|
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
|
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||||
|
StatusPill(
|
||||||
|
bridge = bridgeState,
|
||||||
|
voiceEnabled = voiceEnabled,
|
||||||
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
OverlayIconButton(
|
||||||
|
onClick = { sheet = Sheet.Chat },
|
||||||
|
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
||||||
|
)
|
||||||
|
|
||||||
|
OverlayIconButton(
|
||||||
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSheet = sheet
|
||||||
|
if (currentSheet != null) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { sheet = null },
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
when (currentSheet) {
|
||||||
|
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||||
|
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Sheet {
|
||||||
|
Chat,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverlayIconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
colors =
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = overlayContainerColor(),
|
||||||
|
contentColor = overlayIconColor(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = {
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
webViewClient =
|
||||||
|
object : WebViewClient() {
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
error: WebResourceError,
|
||||||
|
) {
|
||||||
|
if (!isDebuggable) return
|
||||||
|
if (!request.isForMainFrame) return
|
||||||
|
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
errorResponse: WebResourceResponse,
|
||||||
|
) {
|
||||||
|
if (!isDebuggable) return
|
||||||
|
if (!request.isForMainFrame) return
|
||||||
|
Log.e(
|
||||||
|
"ClawdisWebView",
|
||||||
|
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||||
|
|
||||||
|
addJavascriptInterface(
|
||||||
|
CanvasA2UIActionBridge { payload ->
|
||||||
|
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||||
|
},
|
||||||
|
CanvasA2UIActionBridge.interfaceName,
|
||||||
|
)
|
||||||
|
viewModel.canvas.attach(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun postMessage(payload: String?) {
|
||||||
|
val msg = payload?.trim().orEmpty()
|
||||||
|
if (msg.isEmpty()) return
|
||||||
|
onMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
|
import com.steipete.clawdis.node.VoiceWakeMode
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
|
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||||
|
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||||
|
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||||
|
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||||
|
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||||
|
val isConnected by viewModel.isConnected.collectAsState()
|
||||||
|
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
|
val bridges by viewModel.bridges.collectAsState()
|
||||||
|
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
|
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||||
|
viewModel.setCameraEnabled(cameraOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||||
|
// Status text is handled by NodeRuntime.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabledChecked(checked: Boolean) {
|
||||||
|
if (!checked) {
|
||||||
|
viewModel.setCameraEnabled(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraOk =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (cameraOk) {
|
||||||
|
viewModel.setCameraEnabled(true)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleBridges =
|
||||||
|
if (isConnected && remoteAddress != null) {
|
||||||
|
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||||
|
} else {
|
||||||
|
bridges
|
||||||
|
}
|
||||||
|
|
||||||
|
val bridgeDiscoveryFooterText =
|
||||||
|
if (visibleBridges.isEmpty()) {
|
||||||
|
discoveryStatusText
|
||||||
|
} else if (isConnected) {
|
||||||
|
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||||
|
} else {
|
||||||
|
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.imePadding()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||||
|
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = displayName,
|
||||||
|
onValueChange = viewModel::setDisplayName,
|
||||||
|
label = { Text("Name") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
// Bridge
|
||||||
|
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
||||||
|
if (serverName != null) {
|
||||||
|
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
||||||
|
}
|
||||||
|
if (remoteAddress != null) {
|
||||||
|
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
// UI sanity: "Disconnect" only when we have an active remote.
|
||||||
|
if (isConnected && remoteAddress != null) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.disconnect()
|
||||||
|
NodeForegroundService.stop(context)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
if (!isConnected || visibleBridges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
if (isConnected) "Other Bridges" else "Discovered Bridges",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isConnected && visibleBridges.isEmpty()) {
|
||||||
|
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
} else {
|
||||||
|
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(bridge.name) },
|
||||||
|
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
||||||
|
trailingContent = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connect(bridge)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
bridgeDiscoveryFooterText,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Advanced") },
|
||||||
|
supportingContent = { Text("Manual bridge connection") },
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
|
contentDescription = if (advancedExpanded) "Collapse" else "Expand",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
setAdvancedExpanded(!advancedExpanded)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
AnimatedVisibility(visible = advancedExpanded) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Use Manual Bridge") },
|
||||||
|
supportingContent = { Text("Use this when discovery is blocked.") },
|
||||||
|
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualHost,
|
||||||
|
onValueChange = viewModel::setManualHost,
|
||||||
|
label = { Text("Host") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualPort.toString(),
|
||||||
|
onValueChange = { v -> viewModel.setManualPort(v.toIntOrNull() ?: 0) },
|
||||||
|
label = { Text("Port") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = manualEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
val hostOk = manualHost.trim().isNotEmpty()
|
||||||
|
val portOk = manualPort in 1..65535
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connectManual()
|
||||||
|
},
|
||||||
|
enabled = manualEnabled && hostOk && portOk,
|
||||||
|
) {
|
||||||
|
Text("Connect (Manual)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
item { Text("Voice", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item {
|
||||||
|
val enabled = voiceWakeMode != VoiceWakeMode.Off
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Voice Wake") },
|
||||||
|
supportingContent = { Text(voiceWakeStatusText) },
|
||||||
|
trailingContent = {
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
if (on) {
|
||||||
|
val micOk =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||||
|
} else {
|
||||||
|
viewModel.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
AnimatedVisibility(visible = voiceWakeMode != VoiceWakeMode.Off) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Foreground Only") },
|
||||||
|
supportingContent = { Text("Listens only while Clawdis is open.") },
|
||||||
|
trailingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||||
|
onClick = {
|
||||||
|
val micOk =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
viewModel.setVoiceWakeMode(VoiceWakeMode.Foreground)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Always") },
|
||||||
|
supportingContent = { Text("Keeps listening in the background (shows a persistent notification).") },
|
||||||
|
trailingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = voiceWakeMode == VoiceWakeMode.Always,
|
||||||
|
onClick = {
|
||||||
|
val micOk =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
viewModel.setVoiceWakeMode(VoiceWakeMode.Always)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wakeWordsText,
|
||||||
|
onValueChange = setWakeWordsText,
|
||||||
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||||
|
viewModel.setWakeWords(parsed)
|
||||||
|
},
|
||||||
|
enabled = isConnected,
|
||||||
|
) {
|
||||||
|
Text("Save + Sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
if (isConnected) {
|
||||||
|
"Any node can edit wake words. Changes sync via the gateway bridge."
|
||||||
|
} else {
|
||||||
|
"Connect to a gateway to sync wake words globally."
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
item { Text("Camera", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Allow Camera") },
|
||||||
|
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
|
||||||
|
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Tip: grant Microphone permission for video clips with audio.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
|
// Screen
|
||||||
|
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Prevent Sleep") },
|
||||||
|
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
|
||||||
|
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.VerticalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusPill(
|
||||||
|
bridge: BridgeState,
|
||||||
|
voiceEnabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = overlayContainerColor(),
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(9.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = bridge.color,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = bridge.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalDivider(
|
||||||
|
modifier = Modifier.height(14.dp).alpha(0.35f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||||
|
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||||
|
tint =
|
||||||
|
if (voiceEnabled) {
|
||||||
|
overlayIconColor()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BridgeState(val title: String, val color: Color) {
|
||||||
|
Connected("Connected", Color(0xFF2ECC71)),
|
||||||
|
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||||
|
Error("Error", Color(0xFFE74C3C)),
|
||||||
|
Disconnected("Offline", Color(0xFF9E9E9E)),
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatComposer(
|
||||||
|
sessionKey: String,
|
||||||
|
healthOk: Boolean,
|
||||||
|
thinkingLevel: String,
|
||||||
|
pendingRunCount: Int,
|
||||||
|
errorText: String?,
|
||||||
|
attachments: List<PendingImageAttachment>,
|
||||||
|
onPickImages: () -> Unit,
|
||||||
|
onRemoveAttachment: (id: String) -> Unit,
|
||||||
|
onSetThinkingLevel: (level: String) -> Unit,
|
||||||
|
onShowSessions: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onAbort: () -> Unit,
|
||||||
|
onSend: (text: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var input by rememberSaveable { mutableStateOf("") }
|
||||||
|
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { showThinkingMenu = true },
|
||||||
|
contentPadding = ButtonDefaults.ContentPadding,
|
||||||
|
) {
|
||||||
|
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||||
|
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
|
||||||
|
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalIconButton(onClick = onPickImages, modifier = Modifier.size(42.dp)) {
|
||||||
|
Icon(Icons.Default.AttachFile, contentDescription = "Add image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments.isNotEmpty()) {
|
||||||
|
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("Message Clawd…") },
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 6,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (pendingRunCount > 0) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onAbort,
|
||||||
|
colors =
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = Color(0x33E74C3C),
|
||||||
|
contentColor = Color(0xFFE74C3C),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Stop, contentDescription = "Abort")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
val text = input
|
||||||
|
input = ""
|
||||||
|
onSend(text)
|
||||||
|
}, enabled = canSend) {
|
||||||
|
Icon(Icons.Default.ArrowUpward, contentDescription = "Send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errorText.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = errorText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
maxLines = 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(7.dp),
|
||||||
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
|
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||||
|
) {}
|
||||||
|
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||||
|
Text(
|
||||||
|
if (healthOk) "Connected" else "Connecting…",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThinkingMenuItem(
|
||||||
|
value: String,
|
||||||
|
current: String,
|
||||||
|
onSet: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(thinkingLabel(value)) },
|
||||||
|
onClick = {
|
||||||
|
onSet(value)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (value == current.trim().lowercase()) {
|
||||||
|
Text("✓")
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun thinkingLabel(raw: String): String {
|
||||||
|
return when (raw.trim().lowercase()) {
|
||||||
|
"low" -> "Low"
|
||||||
|
"medium" -> "Medium"
|
||||||
|
"high" -> "High"
|
||||||
|
else -> "Off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentsStrip(
|
||||||
|
attachments: List<PendingImageAttachment>,
|
||||||
|
onRemoveAttachment: (id: String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
for (att in attachments) {
|
||||||
|
AttachmentChip(
|
||||||
|
fileName = att.fileName,
|
||||||
|
onRemove = { onRemoveAttachment(att.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(text = fileName, style = MaterialTheme.typography.bodySmall, maxLines = 1)
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(30.dp),
|
||||||
|
) {
|
||||||
|
Text("×")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMarkdown(text: String) {
|
||||||
|
val blocks = remember(text) { splitMarkdown(text) }
|
||||||
|
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
for (b in blocks) {
|
||||||
|
when (b) {
|
||||||
|
is ChatMarkdownBlock.Text -> {
|
||||||
|
val trimmed = b.text.trimEnd()
|
||||||
|
if (trimmed.isEmpty()) continue
|
||||||
|
Text(
|
||||||
|
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ChatMarkdownBlock.Code -> {
|
||||||
|
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
ChatCodeBlock(code = b.code, language = b.language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ChatMarkdownBlock.InlineImage -> {
|
||||||
|
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface ChatMarkdownBlock {
|
||||||
|
data class Text(val text: String) : ChatMarkdownBlock
|
||||||
|
data class Code(val code: String, val language: String?) : ChatMarkdownBlock
|
||||||
|
data class InlineImage(val mimeType: String?, val base64: String) : ChatMarkdownBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitMarkdown(raw: String): List<ChatMarkdownBlock> {
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val out = ArrayList<ChatMarkdownBlock>()
|
||||||
|
var idx = 0
|
||||||
|
while (idx < raw.length) {
|
||||||
|
val fenceStart = raw.indexOf("```", startIndex = idx)
|
||||||
|
if (fenceStart < 0) {
|
||||||
|
out.addAll(splitInlineImages(raw.substring(idx)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fenceStart > idx) {
|
||||||
|
out.addAll(splitInlineImages(raw.substring(idx, fenceStart)))
|
||||||
|
}
|
||||||
|
|
||||||
|
val langLineStart = fenceStart + 3
|
||||||
|
val langLineEnd = raw.indexOf('\n', startIndex = langLineStart).let { if (it < 0) raw.length else it }
|
||||||
|
val language = raw.substring(langLineStart, langLineEnd).trim().ifEmpty { null }
|
||||||
|
|
||||||
|
val codeStart = if (langLineEnd < raw.length && raw[langLineEnd] == '\n') langLineEnd + 1 else langLineEnd
|
||||||
|
val fenceEnd = raw.indexOf("```", startIndex = codeStart)
|
||||||
|
if (fenceEnd < 0) {
|
||||||
|
out.addAll(splitInlineImages(raw.substring(fenceStart)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val code = raw.substring(codeStart, fenceEnd)
|
||||||
|
out.add(ChatMarkdownBlock.Code(code = code, language = language))
|
||||||
|
|
||||||
|
idx = fenceEnd + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
val regex = Regex("data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)")
|
||||||
|
val out = ArrayList<ChatMarkdownBlock>()
|
||||||
|
|
||||||
|
var idx = 0
|
||||||
|
while (idx < text.length) {
|
||||||
|
val m = regex.find(text, startIndex = idx) ?: break
|
||||||
|
val start = m.range.first
|
||||||
|
val end = m.range.last + 1
|
||||||
|
if (start > idx) out.add(ChatMarkdownBlock.Text(text.substring(idx, start)))
|
||||||
|
|
||||||
|
val mime = "image/" + (m.groupValues.getOrNull(1)?.trim()?.ifEmpty { "png" } ?: "png")
|
||||||
|
val b64 = m.groupValues.getOrNull(2)?.replace("\n", "")?.replace("\r", "")?.trim().orEmpty()
|
||||||
|
if (b64.isNotEmpty()) {
|
||||||
|
out.add(ChatMarkdownBlock.InlineImage(mimeType = mime, base64 = b64))
|
||||||
|
}
|
||||||
|
idx = end
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx < text.length) out.add(ChatMarkdownBlock.Text(text.substring(idx)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString {
|
||||||
|
if (text.isEmpty()) return AnnotatedString("")
|
||||||
|
|
||||||
|
val out = buildAnnotatedString {
|
||||||
|
var i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
if (text.startsWith("**", startIndex = i)) {
|
||||||
|
val end = text.indexOf("**", startIndex = i + 2)
|
||||||
|
if (end > i + 2) {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||||
|
append(text.substring(i + 2, end))
|
||||||
|
}
|
||||||
|
i = end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text[i] == '`') {
|
||||||
|
val end = text.indexOf('`', startIndex = i + 1)
|
||||||
|
if (end > i + 1) {
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
background = inlineCodeBg,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(text.substring(i + 1, end))
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text[i] == '*' && (i + 1 < text.length && text[i + 1] != '*')) {
|
||||||
|
val end = text.indexOf('*', startIndex = i + 1)
|
||||||
|
if (end > i + 1) {
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(text.substring(i + 1, end))
|
||||||
|
}
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
append(text[i])
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||||
|
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||||
|
var failed by remember(base64) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(base64) {
|
||||||
|
failed = false
|
||||||
|
image =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||||
|
bitmap.asImageBitmap()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image == null) failed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = image!!,
|
||||||
|
contentDescription = mimeType ?: "image",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else if (failed) {
|
||||||
|
Text(
|
||||||
|
text = "Image unavailable",
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowCircleDown
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMessageListCard(
|
||||||
|
messages: List<ChatMessage>,
|
||||||
|
pendingRunCount: Int,
|
||||||
|
pendingToolCalls: List<ChatPendingToolCall>,
|
||||||
|
streamingAssistantText: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
||||||
|
val total =
|
||||||
|
messages.size +
|
||||||
|
(if (pendingRunCount > 0) 1 else 0) +
|
||||||
|
(if (pendingToolCalls.isNotEmpty()) 1 else 0) +
|
||||||
|
(if (!streamingAssistantText.isNullOrBlank()) 1 else 0)
|
||||||
|
if (total <= 0) return@LaunchedEffect
|
||||||
|
listState.animateScrollToItem(index = total - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
|
||||||
|
) {
|
||||||
|
items(count = messages.size, key = { idx -> messages[idx].id }) { idx ->
|
||||||
|
ChatMessageBubble(message = messages[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingRunCount > 0) {
|
||||||
|
item(key = "typing") {
|
||||||
|
ChatTypingIndicatorBubble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingToolCalls.isNotEmpty()) {
|
||||||
|
item(key = "tools") {
|
||||||
|
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val stream = streamingAssistantText?.trim()
|
||||||
|
if (!stream.isNullOrEmpty()) {
|
||||||
|
item(key = "stream") {
|
||||||
|
ChatStreamingAssistantBubble(text = stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||||
|
EmptyChatHint(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyChatHint(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.alpha(0.7f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowCircleDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Message Clawd…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
|
import com.steipete.clawdis.node.chat.ChatMessageContent
|
||||||
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMessageBubble(message: ChatMessage) {
|
||||||
|
val isUser = message.role.lowercase() == "user"
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
color = Color.Transparent,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.92f),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(bubbleBackground(isUser))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
ChatMessageBody(content = message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatMessageBody(content: List<ChatMessageContent>) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
for (part in content) {
|
||||||
|
when (part.type) {
|
||||||
|
"text" -> {
|
||||||
|
val text = part.text ?: continue
|
||||||
|
ChatMarkdown(text = text)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val b64 = part.base64 ?: continue
|
||||||
|
ChatBase64Image(base64 = b64, mimeType = part.mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatTypingIndicatorBubble() {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
DotPulse()
|
||||||
|
Text("Thinking…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
for (t in toolCalls.take(6)) {
|
||||||
|
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
if (toolCalls.size > 6) {
|
||||||
|
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatStreamingAssistantBubble(text: String) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||||
|
ChatMarkdown(text = text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun bubbleBackground(isUser: Boolean): Brush {
|
||||||
|
return if (isUser) {
|
||||||
|
Brush.linearGradient(
|
||||||
|
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.78f)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Brush.linearGradient(
|
||||||
|
colors = listOf(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||||
|
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||||
|
var failed by remember(base64) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(base64) {
|
||||||
|
failed = false
|
||||||
|
image =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||||
|
bitmap.asImageBitmap()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image == null) failed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = image!!,
|
||||||
|
contentDescription = mimeType ?: "attachment",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else if (failed) {
|
||||||
|
Text("Unsupported attachment", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DotPulse() {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PulseDot(alpha = 0.38f)
|
||||||
|
PulseDot(alpha = 0.62f)
|
||||||
|
PulseDot(alpha = 0.90f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PulseDot(alpha: Float) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(6.dp).alpha(alpha),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatCodeBlock(code: String, language: String?) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = code.trimEnd(),
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatSessionsDialog(
|
||||||
|
currentSessionKey: String,
|
||||||
|
sessions: List<ChatSessionEntry>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onSelect: (sessionKey: String) -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {},
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Sessions", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
FilledTonalIconButton(onClick = onRefresh) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
if (sessions.isEmpty()) {
|
||||||
|
Text("No sessions", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
} else {
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(sessions, key = { it.key }) { entry ->
|
||||||
|
SessionRow(
|
||||||
|
entry = entry,
|
||||||
|
isCurrent = entry.key == currentSessionKey,
|
||||||
|
onClick = { onSelect(entry.key) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SessionRow(
|
||||||
|
entry: ChatSessionEntry,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color =
|
||||||
|
if (isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (isCurrent) {
|
||||||
|
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||||
|
val messages by viewModel.chatMessages.collectAsState()
|
||||||
|
val errorText by viewModel.chatError.collectAsState()
|
||||||
|
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||||
|
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||||
|
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||||
|
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||||
|
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||||
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
|
||||||
|
var showSessions by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadChat("main")
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
|
||||||
|
|
||||||
|
val pickImages =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||||
|
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val next =
|
||||||
|
uris.take(8).mapNotNull { uri ->
|
||||||
|
try {
|
||||||
|
loadImageAttachment(resolver, uri)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
attachments.addAll(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
ChatMessageListCard(
|
||||||
|
messages = messages,
|
||||||
|
pendingRunCount = pendingRunCount,
|
||||||
|
pendingToolCalls = pendingToolCalls,
|
||||||
|
streamingAssistantText = streamingAssistantText,
|
||||||
|
modifier = Modifier.weight(1f, fill = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatComposer(
|
||||||
|
sessionKey = sessionKey,
|
||||||
|
healthOk = healthOk,
|
||||||
|
thinkingLevel = thinkingLevel,
|
||||||
|
pendingRunCount = pendingRunCount,
|
||||||
|
errorText = errorText,
|
||||||
|
attachments = attachments,
|
||||||
|
onPickImages = { pickImages.launch("image/*") },
|
||||||
|
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||||
|
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||||
|
onShowSessions = { showSessions = true },
|
||||||
|
onRefresh = { viewModel.refreshChat() },
|
||||||
|
onAbort = { viewModel.abortChat() },
|
||||||
|
onSend = { text ->
|
||||||
|
val outgoing =
|
||||||
|
attachments.map { att ->
|
||||||
|
OutgoingAttachment(
|
||||||
|
type = "image",
|
||||||
|
mimeType = att.mimeType,
|
||||||
|
fileName = att.fileName,
|
||||||
|
base64 = att.base64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||||
|
attachments.clear()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSessions) {
|
||||||
|
ChatSessionsDialog(
|
||||||
|
currentSessionKey = sessionKey,
|
||||||
|
sessions = sessions,
|
||||||
|
onDismiss = { showSessions = false },
|
||||||
|
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
|
||||||
|
onSelect = { key ->
|
||||||
|
viewModel.switchChatSession(key)
|
||||||
|
showSessions = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PendingImageAttachment(
|
||||||
|
val id: String,
|
||||||
|
val fileName: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val base64: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||||
|
val mimeType = resolver.getType(uri) ?: "image/*"
|
||||||
|
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
||||||
|
val bytes =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
resolver.openInputStream(uri)?.use { input ->
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
input.copyTo(out)
|
||||||
|
out.toByteArray()
|
||||||
|
} ?: ByteArray(0)
|
||||||
|
}
|
||||||
|
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
||||||
|
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||||
|
return PendingImageAttachment(
|
||||||
|
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
base64 = base64,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.steipete.clawdis.node.voice
|
||||||
|
|
||||||
|
object VoiceWakeCommandExtractor {
|
||||||
|
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
||||||
|
val raw = text.trim()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
|
||||||
|
val triggers =
|
||||||
|
triggerWords
|
||||||
|
.map { it.trim().lowercase() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
if (triggers.isEmpty()) return null
|
||||||
|
|
||||||
|
val alternation = triggers.joinToString("|") { Regex.escape(it) }
|
||||||
|
// Match: "<anything> <trigger><punct/space> <command>"
|
||||||
|
val regex = Regex("(?i)(?:^|\\s)($alternation)\\b[\\s\\p{Punct}]*([\\s\\S]+)$")
|
||||||
|
val match = regex.find(raw) ?: return null
|
||||||
|
val extracted = match.groupValues.getOrNull(2)?.trim().orEmpty()
|
||||||
|
if (extracted.isEmpty()) return null
|
||||||
|
|
||||||
|
val cleaned = extracted.trimStart { it.isWhitespace() || it.isPunctuation() }.trim()
|
||||||
|
if (cleaned.isEmpty()) return null
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Char.isPunctuation(): Boolean {
|
||||||
|
return when (Character.getType(this)) {
|
||||||
|
Character.CONNECTOR_PUNCTUATION.toInt(),
|
||||||
|
Character.DASH_PUNCTUATION.toInt(),
|
||||||
|
Character.START_PUNCTUATION.toInt(),
|
||||||
|
Character.END_PUNCTUATION.toInt(),
|
||||||
|
Character.INITIAL_QUOTE_PUNCTUATION.toInt(),
|
||||||
|
Character.FINAL_QUOTE_PUNCTUATION.toInt(),
|
||||||
|
Character.OTHER_PUNCTUATION.toInt(),
|
||||||
|
-> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.steipete.clawdis.node.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.speech.RecognitionListener
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class VoiceWakeManager(
|
||||||
|
private val context: Context,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onCommand: suspend (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
val isListening: StateFlow<Boolean> = _isListening
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Off")
|
||||||
|
val statusText: StateFlow<String> = _statusText
|
||||||
|
|
||||||
|
var triggerWords: List<String> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var recognizer: SpeechRecognizer? = null
|
||||||
|
private var restartJob: Job? = null
|
||||||
|
private var lastDispatched: String? = null
|
||||||
|
private var stopRequested = false
|
||||||
|
|
||||||
|
fun setTriggerWords(words: List<String>) {
|
||||||
|
triggerWords = words
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
mainHandler.post {
|
||||||
|
if (_isListening.value) return@post
|
||||||
|
stopRequested = false
|
||||||
|
|
||||||
|
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||||
|
_isListening.value = false
|
||||||
|
_statusText.value = "Speech recognizer unavailable"
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recognizer?.destroy()
|
||||||
|
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||||
|
startListeningInternal()
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
_isListening.value = false
|
||||||
|
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(statusText: String = "Off") {
|
||||||
|
stopRequested = true
|
||||||
|
restartJob?.cancel()
|
||||||
|
restartJob = null
|
||||||
|
mainHandler.post {
|
||||||
|
_isListening.value = false
|
||||||
|
_statusText.value = statusText
|
||||||
|
recognizer?.cancel()
|
||||||
|
recognizer?.destroy()
|
||||||
|
recognizer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startListeningInternal() {
|
||||||
|
val r = recognizer ?: return
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
_statusText.value = "Listening"
|
||||||
|
_isListening.value = true
|
||||||
|
r.startListening(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRestart(delayMs: Long = 350) {
|
||||||
|
if (stopRequested) return
|
||||||
|
restartJob?.cancel()
|
||||||
|
restartJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
mainHandler.post {
|
||||||
|
if (stopRequested) return@post
|
||||||
|
try {
|
||||||
|
recognizer?.cancel()
|
||||||
|
startListeningInternal()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Will be picked up by onError and retry again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleTranscription(text: String) {
|
||||||
|
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
|
||||||
|
if (command == lastDispatched) return
|
||||||
|
lastDispatched = command
|
||||||
|
|
||||||
|
scope.launch { onCommand(command) }
|
||||||
|
_statusText.value = "Triggered"
|
||||||
|
scheduleRestart(delayMs = 650)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listener =
|
||||||
|
object : RecognitionListener {
|
||||||
|
override fun onReadyForSpeech(params: Bundle?) {
|
||||||
|
_statusText.value = "Listening"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBeginningOfSpeech() {}
|
||||||
|
|
||||||
|
override fun onRmsChanged(rmsdB: Float) {}
|
||||||
|
|
||||||
|
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||||
|
|
||||||
|
override fun onEndOfSpeech() {
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: Int) {
|
||||||
|
if (stopRequested) return
|
||||||
|
_isListening.value = false
|
||||||
|
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
|
||||||
|
_statusText.value = "Microphone permission required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_statusText.value =
|
||||||
|
when (error) {
|
||||||
|
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
|
||||||
|
SpeechRecognizer.ERROR_CLIENT -> "Client error"
|
||||||
|
SpeechRecognizer.ERROR_NETWORK -> "Network error"
|
||||||
|
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
|
||||||
|
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
|
||||||
|
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
|
||||||
|
SpeechRecognizer.ERROR_SERVER -> "Server error"
|
||||||
|
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
|
||||||
|
else -> "Speech error ($error)"
|
||||||
|
}
|
||||||
|
scheduleRestart(delayMs = 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResults(results: Bundle?) {
|
||||||
|
val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty()
|
||||||
|
list.firstOrNull()?.let(::handleTranscription)
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPartialResults(partialResults: Bundle?) {
|
||||||
|
val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty()
|
||||||
|
list.firstOrNull()?.let(::handleTranscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Clawdis Node</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.ClawdisNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">clawdis.internal</domain>
|
||||||
|
</domain-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">ts.net</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class WakeWordsTest {
|
||||||
|
@Test
|
||||||
|
fun parseCommaSeparatedTrimsAndDropsEmpty() {
|
||||||
|
assertEquals(listOf("clawd", "claude"), WakeWords.parseCommaSeparated(" clawd , claude, , "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sanitizeTrimsCapsAndFallsBack() {
|
||||||
|
val defaults = listOf("clawd", "claude")
|
||||||
|
val long = "x".repeat(WakeWords.maxWordLength + 10)
|
||||||
|
val words = listOf(" ", " hello ", long)
|
||||||
|
|
||||||
|
val sanitized = WakeWords.sanitize(words, defaults)
|
||||||
|
assertEquals(2, sanitized.size)
|
||||||
|
assertEquals("hello", sanitized[0])
|
||||||
|
assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1])
|
||||||
|
|
||||||
|
assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sanitizeLimitsWordCount() {
|
||||||
|
val defaults = listOf("clawd")
|
||||||
|
val words = (1..(WakeWords.maxWords + 5)).map { "w$it" }
|
||||||
|
val sanitized = WakeWords.sanitize(words, defaults)
|
||||||
|
assertEquals(WakeWords.maxWords, sanitized.size)
|
||||||
|
assertEquals("w1", sanitized.first())
|
||||||
|
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BonjourEscapesTest {
|
||||||
|
@Test
|
||||||
|
fun decodeNoop() {
|
||||||
|
assertEquals("", BonjourEscapes.decode(""))
|
||||||
|
assertEquals("hello", BonjourEscapes.decode("hello"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeDecodesDecimalEscapes() {
|
||||||
|
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
|
||||||
|
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
class BridgePairingClientTest {
|
||||||
|
@Test
|
||||||
|
fun helloOkReturnsExistingToken() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val hello = reader.readLine()
|
||||||
|
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = BridgePairingClient()
|
||||||
|
val res =
|
||||||
|
client.pairAndHello(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = "token-123",
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = "SM-X000",
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(res.ok)
|
||||||
|
assertEquals("token-123", res.token)
|
||||||
|
server.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notPairedTriggersPairRequestAndReturnsToken() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"error","code":"NOT_PAIRED","message":"not paired"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val pairReq = reader.readLine()
|
||||||
|
assertTrue(pairReq.contains("\"type\":\"pair-request\""))
|
||||||
|
writer.write("""{"type":"pair-ok","token":"new-token"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = BridgePairingClient()
|
||||||
|
val res =
|
||||||
|
client.pairAndHello(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = "SM-X000",
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(res.ok)
|
||||||
|
assertEquals("new-token", res.token)
|
||||||
|
server.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class BridgeSessionTest {
|
||||||
|
@Test
|
||||||
|
fun requestReturnsPayloadJson() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val hello = reader.readLine()
|
||||||
|
assertTrue(hello.contains("\"type\":\"hello\""))
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val req = reader.readLine()
|
||||||
|
assertTrue(req.contains("\"type\":\"req\""))
|
||||||
|
val id = extractJsonString(req, "id")
|
||||||
|
writer.write("""{"type":"res","id":"$id","ok":true,"payloadJSON":"{\"value\":123}"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = null,
|
||||||
|
modelIdentifier = null,
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connected.await()
|
||||||
|
val payload = session.request(method = "health", paramsJson = null)
|
||||||
|
assertEquals("""{"value":123}""", payload)
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestThrowsOnErrorResponse() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val req = reader.readLine()
|
||||||
|
val id = extractJsonString(req, "id")
|
||||||
|
writer.write(
|
||||||
|
"""{"type":"res","id":"$id","ok":false,"error":{"code":"FORBIDDEN","message":"nope"}}""",
|
||||||
|
)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = null,
|
||||||
|
modelIdentifier = null,
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connected.await()
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.request(method = "chat.history", paramsJson = """{"sessionKey":"main"}""")
|
||||||
|
throw AssertionError("expected request() to throw")
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertTrue(e.message?.contains("FORBIDDEN: nope") == true)
|
||||||
|
}
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invokeResReturnsErrorWhenHandlerThrows() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||||
|
)
|
||||||
|
|
||||||
|
val invokeResLine = CompletableDeferred<String>()
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
val sock = ss.accept()
|
||||||
|
sock.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
// Ask the node to invoke something; handler will throw.
|
||||||
|
writer.write("""{"type":"invoke","id":"i1","command":"canvas.snapshot","paramsJSON":null}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
|
||||||
|
val res = reader.readLine()
|
||||||
|
invokeResLine.complete(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = null,
|
||||||
|
modelIdentifier = null,
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connected.await()
|
||||||
|
|
||||||
|
// Give the reader loop time to process.
|
||||||
|
val line = invokeResLine.await()
|
||||||
|
assertTrue(line.contains("\"type\":\"invoke-res\""))
|
||||||
|
assertTrue(line.contains("\"ok\":false"))
|
||||||
|
assertTrue(line.contains("\"code\":\"FOO_BAR\""))
|
||||||
|
assertTrue(line.contains("\"message\":\"boom\""))
|
||||||
|
server.await()
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(timeout = 12_000)
|
||||||
|
fun reconnectsAfterBridgeClosesDuringHello() = runBlocking {
|
||||||
|
val serverSocket = ServerSocket(0)
|
||||||
|
val port = serverSocket.localPort
|
||||||
|
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val connected = CountDownLatch(1)
|
||||||
|
val connectionsSeen = CountDownLatch(2)
|
||||||
|
|
||||||
|
val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { _, _ -> connected.countDown() },
|
||||||
|
onDisconnected = { /* ignore */ },
|
||||||
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val server =
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
serverSocket.use { ss ->
|
||||||
|
// First connection: read hello, then close (no response).
|
||||||
|
val sock1 = ss.accept()
|
||||||
|
sock1.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
reader.readLine() // hello
|
||||||
|
connectionsSeen.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second connection: complete hello.
|
||||||
|
val sock2 = ss.accept()
|
||||||
|
sock2.use { s ->
|
||||||
|
val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8))
|
||||||
|
reader.readLine() // hello
|
||||||
|
writer.write("""{"type":"hello-ok","serverName":"Test Bridge"}""")
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
connectionsSeen.countDown()
|
||||||
|
Thread.sleep(200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.connect(
|
||||||
|
endpoint = BridgeEndpoint.manual(host = "127.0.0.1", port = port),
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = "node-1",
|
||||||
|
displayName = "Android Node",
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "test",
|
||||||
|
deviceFamily = null,
|
||||||
|
modelIdentifier = null,
|
||||||
|
caps = null,
|
||||||
|
commands = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue("expected two connection attempts", connectionsSeen.await(8, TimeUnit.SECONDS))
|
||||||
|
assertTrue("expected session to connect", connected.await(8, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
session.disconnect()
|
||||||
|
scope.cancel()
|
||||||
|
server.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractJsonString(raw: String, key: String): String {
|
||||||
|
val needle = "\"$key\":\""
|
||||||
|
val start = raw.indexOf(needle)
|
||||||
|
if (start < 0) throw IllegalArgumentException("missing key $key in $raw")
|
||||||
|
val from = start + needle.length
|
||||||
|
val end = raw.indexOf('"', from)
|
||||||
|
if (end < 0) throw IllegalArgumentException("unterminated string for $key in $raw")
|
||||||
|
return raw.substring(from, end)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CanvasControllerSnapshotParamsTest {
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsDefaultsToJpeg() {
|
||||||
|
val params = CanvasController.parseSnapshotParams(null)
|
||||||
|
assertEquals(CanvasController.SnapshotFormat.Jpeg, params.format)
|
||||||
|
assertNull(params.quality)
|
||||||
|
assertNull(params.maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsParsesPng() {
|
||||||
|
val params = CanvasController.parseSnapshotParams("""{"format":"png","maxWidth":900}""")
|
||||||
|
assertEquals(CanvasController.SnapshotFormat.Png, params.format)
|
||||||
|
assertEquals(900, params.maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsParsesJpegAliases() {
|
||||||
|
assertEquals(
|
||||||
|
CanvasController.SnapshotFormat.Jpeg,
|
||||||
|
CanvasController.parseSnapshotParams("""{"format":"jpeg"}""").format,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CanvasController.SnapshotFormat.Jpeg,
|
||||||
|
CanvasController.parseSnapshotParams("""{"format":"jpg"}""").format,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSnapshotParamsClampsQuality() {
|
||||||
|
val low = CanvasController.parseSnapshotParams("""{"quality":0.01}""")
|
||||||
|
assertEquals(0.1, low.quality)
|
||||||
|
|
||||||
|
val high = CanvasController.parseSnapshotParams("""{"quality":5}""")
|
||||||
|
assertEquals(1.0, high.quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClawdisCanvasA2UIActionTest {
|
||||||
|
@Test
|
||||||
|
fun formatAgentMessageMatchesSharedSpec() {
|
||||||
|
val msg =
|
||||||
|
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
|
actionName = "Get Weather",
|
||||||
|
sessionKey = "main",
|
||||||
|
surfaceId = "main",
|
||||||
|
sourceComponentId = "btnWeather",
|
||||||
|
host = "Peter’s iPad",
|
||||||
|
instanceId = "ipad16,6",
|
||||||
|
contextJson = "{\"city\":\"Vienna\"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"CANVAS_A2UI action=Get_Weather session=main surface=main component=btnWeather host=Peter_s_iPad instance=ipad16_6 ctx={\"city\":\"Vienna\"} default=update_canvas",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jsDispatchA2uiStatusIsStable() {
|
||||||
|
val js = ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId = "a1", ok = true, error = null)
|
||||||
|
assertEquals(
|
||||||
|
"window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"a1\", ok: true, error: \"\" } }));",
|
||||||
|
js,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ClawdisProtocolConstantsTest {
|
||||||
|
@Test
|
||||||
|
fun canvasCommandsUseStableStrings() {
|
||||||
|
assertEquals("canvas.present", ClawdisCanvasCommand.Present.rawValue)
|
||||||
|
assertEquals("canvas.hide", ClawdisCanvasCommand.Hide.rawValue)
|
||||||
|
assertEquals("canvas.navigate", ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
|
assertEquals("canvas.eval", ClawdisCanvasCommand.Eval.rawValue)
|
||||||
|
assertEquals("canvas.snapshot", ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun a2uiCommandsUseStableStrings() {
|
||||||
|
assertEquals("canvas.a2ui.push", ClawdisCanvasA2UICommand.Push.rawValue)
|
||||||
|
assertEquals("canvas.a2ui.pushJSONL", ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
|
assertEquals("canvas.a2ui.reset", ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun capabilitiesUseStableStrings() {
|
||||||
|
assertEquals("canvas", ClawdisCapability.Canvas.rawValue)
|
||||||
|
assertEquals("camera", ClawdisCapability.Camera.rawValue)
|
||||||
|
assertEquals("screen", ClawdisCapability.Screen.rawValue)
|
||||||
|
assertEquals("voiceWake", ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun screenCommandsUseStableStrings() {
|
||||||
|
assertEquals("screen.record", ClawdisScreenCommand.Record.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.steipete.clawdis.node.voice
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VoiceWakeCommandExtractorTest {
|
||||||
|
@Test
|
||||||
|
fun extractsCommandAfterTriggerWord() {
|
||||||
|
val res = VoiceWakeCommandExtractor.extractCommand("Claude take a photo", listOf("clawd", "claude"))
|
||||||
|
assertEquals("take a photo", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractsCommandWithPunctuation() {
|
||||||
|
val res = VoiceWakeCommandExtractor.extractCommand("hey clawd, what's the weather?", listOf("clawd"))
|
||||||
|
assertEquals("what's the weather?", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsNullWhenNoCommandProvided() {
|
||||||
|
assertNull(VoiceWakeCommandExtractor.extractCommand("claude", listOf("claude")))
|
||||||
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.21" 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
||||||
|
org.gradle.warning.mode=none
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ClawdisNodeAndroid"
|
||||||
|
include(":app")
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ClawdisNode (iOS)
|
# Clawdis (iOS)
|
||||||
|
|
||||||
Internal-only SwiftUI app scaffold.
|
Internal-only SwiftUI app scaffold.
|
||||||
|
|
||||||
@@ -11,8 +11,18 @@ brew install swiftformat swiftlint
|
|||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
open ClawdisNode.xcodeproj
|
open Clawdis.xcodeproj
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shared packages
|
## Shared packages
|
||||||
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
- `../shared/ClawdisKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||||
|
|
||||||
|
## fastlane
|
||||||
|
```bash
|
||||||
|
brew install fastlane
|
||||||
|
|
||||||
|
cd apps/ios
|
||||||
|
fastlane lanes
|
||||||
|
```
|
||||||
|
|
||||||
|
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" },
|
||||||
|
{ "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" },
|
||||||
|
{ "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" },
|
||||||
|
{ "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" },
|
||||||
|
{ "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
||||||
|
|
||||||
|
{ "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,76 +5,86 @@ import Network
|
|||||||
actor BridgeClient {
|
actor BridgeClient {
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
private var lineBuffer = Data()
|
||||||
|
|
||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
nodeId: String,
|
hello: BridgeHello,
|
||||||
displayName: String?,
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
platform: String,
|
|
||||||
version: String,
|
|
||||||
existingToken: String?) async throws -> String
|
|
||||||
{
|
{
|
||||||
|
self.lineBuffer = Data()
|
||||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
|
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
|
||||||
connection.start(queue: queue)
|
defer { connection.cancel() }
|
||||||
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
|
try await self.startAndWaitForReady(connection, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
let token = existingToken
|
onStatus?("Authenticating…")
|
||||||
try await self.send(
|
try await self.send(hello, over: connection)
|
||||||
BridgeHello(
|
|
||||||
nodeId: nodeId,
|
|
||||||
displayName: displayName,
|
|
||||||
token: token,
|
|
||||||
platform: platform,
|
|
||||||
version: version),
|
|
||||||
over: connection)
|
|
||||||
|
|
||||||
if let line = try await self.receiveLine(over: connection),
|
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||||
let data = line.data(using: .utf8),
|
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
{
|
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||||
if base.type == "hello-ok" {
|
])
|
||||||
connection.cancel()
|
|
||||||
return existingToken ?? ""
|
|
||||||
}
|
}
|
||||||
if base.type == "error" {
|
return frame
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
}
|
||||||
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
|
|
||||||
try await self.send(
|
|
||||||
BridgePairRequest(
|
|
||||||
nodeId: nodeId,
|
|
||||||
displayName: displayName,
|
|
||||||
platform: platform,
|
|
||||||
version: version),
|
|
||||||
over: connection)
|
|
||||||
|
|
||||||
while let next = try await self.receiveLine(over: connection) {
|
switch first.base.type {
|
||||||
guard let nextData = next.data(using: .utf8) else { continue }
|
case "hello-ok":
|
||||||
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
|
// We only return a token if we have one; callers should treat empty as "no token yet".
|
||||||
if nextBase.type == "pair-ok" {
|
return hello.token ?? ""
|
||||||
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
|
|
||||||
connection.cancel()
|
case "error":
|
||||||
return ok.token
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||||
}
|
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||||
if nextBase.type == "error" {
|
|
||||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
|
|
||||||
connection.cancel()
|
|
||||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.cancel()
|
|
||||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
connection.cancel()
|
onStatus?("Requesting approval…")
|
||||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
try await self.send(
|
||||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
BridgePairRequest(
|
||||||
])
|
nodeId: hello.nodeId,
|
||||||
|
displayName: hello.displayName,
|
||||||
|
platform: hello.platform,
|
||||||
|
version: hello.version,
|
||||||
|
deviceFamily: hello.deviceFamily,
|
||||||
|
modelIdentifier: hello.modelIdentifier,
|
||||||
|
caps: hello.caps,
|
||||||
|
commands: hello.commands),
|
||||||
|
over: connection)
|
||||||
|
|
||||||
|
onStatus?("Waiting for approval…")
|
||||||
|
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
|
||||||
|
while let next = try await self.receiveFrame(over: connection) {
|
||||||
|
switch next.base.type {
|
||||||
|
case "pair-ok":
|
||||||
|
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||||
|
case "error":
|
||||||
|
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||||
|
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok.token
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||||
@@ -89,18 +99,17 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveLine(over connection: NWConnection) async throws -> String? {
|
private struct ReceivedFrame {
|
||||||
var buffer = Data()
|
var base: BridgeBaseFrame
|
||||||
while true {
|
var data: Data
|
||||||
if let idx = buffer.firstIndex(of: 0x0A) {
|
}
|
||||||
let lineData = buffer.prefix(upTo: idx)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk = try await self.receiveChunk(over: connection)
|
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
|
||||||
if chunk.isEmpty { return nil }
|
guard let lineData = try await self.receiveLineData(over: connection) else {
|
||||||
buffer.append(chunk)
|
return nil
|
||||||
}
|
}
|
||||||
|
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||||
|
return ReceivedFrame(base: base, data: lineData)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||||
@@ -118,4 +127,88 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
|
||||||
|
while true {
|
||||||
|
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
|
||||||
|
let line = self.lineBuffer.prefix(upTo: idx)
|
||||||
|
self.lineBuffer.removeSubrange(...idx)
|
||||||
|
return Data(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = try await self.receiveChunk(over: connection)
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
self.lineBuffer.append(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TimeoutError: LocalizedError, Sendable {
|
||||||
|
var purpose: String
|
||||||
|
var seconds: Int
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
if self.purpose == "pairing approval" {
|
||||||
|
return
|
||||||
|
"Timed out waiting for approval (\(self.seconds)s). " +
|
||||||
|
"Approve the node on your gateway and try again."
|
||||||
|
}
|
||||||
|
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withTimeout<T: Sendable>(
|
||||||
|
seconds: Int,
|
||||||
|
purpose: String,
|
||||||
|
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
|
{
|
||||||
|
try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
try await op()
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||||
|
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||||
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
final class ResumeFlag: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value = false
|
||||||
|
|
||||||
|
func trySet() -> Bool {
|
||||||
|
self.lock.lock()
|
||||||
|
defer { self.lock.unlock() }
|
||||||
|
if self.value { return false }
|
||||||
|
self.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let didResume = ResumeFlag()
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
if didResume.trySet() { cont.resume(returning: ()) }
|
||||||
|
case let .failed(err):
|
||||||
|
if didResume.trySet() { cont.resume(throwing: err) }
|
||||||
|
case let .waiting(err):
|
||||||
|
if didResume.trySet() { cont.resume(throwing: err) }
|
||||||
|
case .cancelled:
|
||||||
|
if didResume.trySet() {
|
||||||
|
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||