Compare commits
2612 Commits
pr17
...
feat/andro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3094b72bea | ||
|
|
a63af8d8ff | ||
|
|
40ffbd32cb | ||
|
|
d923dc56ec | ||
|
|
5eb6b779f5 | ||
|
|
0928e3c866 | ||
|
|
734bb6b4fd | ||
|
|
a2ba7ddf90 | ||
|
|
64e656af82 | ||
|
|
a2d7632cf3 | ||
|
|
17665d1732 | ||
|
|
4e072d59c1 | ||
|
|
94da41dc52 | ||
|
|
718299b25a | ||
|
|
e80bd1882f | ||
|
|
ca09078934 | ||
|
|
c54fcd1e74 | ||
|
|
5f09d801d0 | ||
|
|
65ad956ab4 | ||
|
|
20e41c5a10 | ||
|
|
5d29985c4f | ||
|
|
026a25d164 | ||
|
|
fd95ededaa | ||
|
|
c0b248f291 | ||
|
|
e8de7d083d | ||
|
|
21826cdfb9 | ||
|
|
8f53e9093d | ||
|
|
30d5511058 | ||
|
|
c6b8235862 | ||
|
|
557aa74ee8 | ||
|
|
7ff318d3f2 | ||
|
|
8ff802a072 | ||
|
|
b79fdd2be8 | ||
|
|
246adaa119 | ||
|
|
d48dc71fa4 | ||
|
|
1e555e693a | ||
|
|
ec09b06636 | ||
|
|
378e4c9b6b | ||
|
|
5ce1eb791e | ||
|
|
529cf91ac3 | ||
|
|
672700f2b3 | ||
|
|
476bbd2915 | ||
|
|
9616add9b1 | ||
|
|
71fdf46f18 | ||
|
|
0d56a73118 | ||
|
|
1318276105 | ||
|
|
7aab2ae182 | ||
|
|
ec6980cda0 | ||
|
|
b234d82bf3 | ||
|
|
9958283ced | ||
|
|
d6f8b6ac51 | ||
|
|
8c38a7fee8 | ||
|
|
02d7e286ea | ||
|
|
3910b9b39e | ||
|
|
607de4a403 | ||
|
|
7701d395e9 | ||
|
|
0085b2e0a9 | ||
|
|
bf3d120f8c | ||
|
|
4b3ca29404 | ||
|
|
259b14d66a | ||
|
|
c9504a6f20 | ||
|
|
5e36e2c3f3 | ||
|
|
d2da305190 | ||
|
|
be9fa124df | ||
|
|
ff88f3c075 | ||
|
|
1315fc4caf | ||
|
|
a03895dfa9 | ||
|
|
40c3898ca1 | ||
|
|
6ea0eb438c | ||
|
|
04cd1bd11a | ||
|
|
fe0b3500cc | ||
|
|
b978cc4e91 | ||
|
|
72a9e58777 | ||
|
|
fd91da2b7f | ||
|
|
5673f4299a | ||
|
|
770daadaf7 | ||
|
|
13c2f22240 | ||
|
|
f2ce455c8c | ||
|
|
640ec465d7 | ||
|
|
70f79bd926 | ||
|
|
7d95f43a75 | ||
|
|
c2f3b653c2 | ||
|
|
12ba32c724 | ||
|
|
0e75aa2716 | ||
|
|
44990d837f | ||
|
|
3a28e3562c | ||
|
|
24aa3e3311 | ||
|
|
3c4c2aa98c | ||
|
|
3ebee63cb3 | ||
|
|
6d6038b855 | ||
|
|
55876f7be0 | ||
|
|
cd3c42d0c0 | ||
|
|
add1301a51 | ||
|
|
052cec70ae | ||
|
|
534de59f7c | ||
|
|
1d06164e18 | ||
|
|
fe67073b74 | ||
|
|
cbf41859aa | ||
|
|
12186e14a9 | ||
|
|
fbaa109a3a | ||
|
|
70d68d29d0 | ||
|
|
e7615c464a | ||
|
|
a1780efb9f | ||
|
|
53d954695e | ||
|
|
44bdd4ca0c | ||
|
|
8724c2aea8 | ||
|
|
e3c543ec06 | ||
|
|
412e8b3aee | ||
|
|
5862f95bd2 | ||
|
|
e17c038d18 | ||
|
|
e1dd764504 | ||
|
|
52f59e6dc1 | ||
|
|
3bc24bf179 | ||
|
|
e07fdd117d | ||
|
|
7c062e0ef2 | ||
|
|
0f1781fc2c | ||
|
|
0f6e566a20 | ||
|
|
03ee77b0e1 | ||
|
|
86038ec165 | ||
|
|
e7c9b9a749 | ||
|
|
919d5d1dbb | ||
|
|
3f7c69fa7f | ||
|
|
cc07ea82a4 | ||
|
|
30e22769bb | ||
|
|
6c406b488d | ||
|
|
f13f89e8b9 | ||
|
|
8b069e62fc | ||
|
|
e2709a3ebd | ||
|
|
18a89a31af | ||
|
|
934f891932 | ||
|
|
5493772910 | ||
|
|
c533593d8e | ||
|
|
fe1b894676 | ||
|
|
d88581eb7c | ||
|
|
3d39e2ad75 | ||
|
|
2dc10ce337 | ||
|
|
d8a417f7ff | ||
|
|
107dc1aa42 | ||
|
|
9d2d0c64c2 | ||
|
|
3872f32419 | ||
|
|
3b075dff8a | ||
|
|
7bad9f3fbd | ||
|
|
16e3535ac0 | ||
|
|
a15cffb7de | ||
|
|
03c1599544 | ||
|
|
6464d93bbb | ||
|
|
424d31af1f | ||
|
|
e9d7ac8e84 | ||
|
|
fac694fc03 | ||
|
|
3e84b9632d | ||
|
|
ce3fd09e14 | ||
|
|
641080a0b6 | ||
|
|
99c3fc1128 | ||
|
|
8c7b2aa2d3 | ||
|
|
55a07a0ef0 | ||
|
|
9899ba53a3 | ||
|
|
52458a5628 | ||
|
|
7abd6713c8 | ||
|
|
451174ca10 | ||
|
|
cdfbd6e7eb | ||
|
|
350e007a5c | ||
|
|
5e156135a1 | ||
|
|
b7ec9ae475 | ||
|
|
8a18af409d | ||
|
|
6a125b554b | ||
|
|
ce92fac983 | ||
|
|
341a224301 | ||
|
|
95cd153f33 | ||
|
|
0af89022ff | ||
|
|
27a8f3d061 | ||
|
|
72b34f7d03 | ||
|
|
73fa2e10bc | ||
|
|
4a6b33d799 | ||
|
|
145964c85e | ||
|
|
217b84f2ac | ||
|
|
1d6de24ab3 | ||
|
|
822def84d2 | ||
|
|
f313af75e9 | ||
|
|
591773715e | ||
|
|
dd6b9b510b | ||
|
|
6ae51ae3de | ||
|
|
00c3e98431 | ||
|
|
dd561f58d1 | ||
|
|
200dd634fb | ||
|
|
3bbdcaf87f | ||
|
|
abff5e3b1f | ||
|
|
40ee0f0672 | ||
|
|
9f8eeceae7 | ||
|
|
53baba71fa | ||
|
|
0f85080d81 | ||
|
|
72f8148080 | ||
|
|
be3da5b856 | ||
|
|
9a9b429f74 | ||
|
|
733e86516e | ||
|
|
1a00175eb7 | ||
|
|
77c76ca52f | ||
|
|
5de3395204 | ||
|
|
4e4655f607 | ||
|
|
48731f494b | ||
|
|
4fcd89c3d9 | ||
|
|
a4f433a1b1 | ||
|
|
84a7ee491b | ||
|
|
3043dd3a0c | ||
|
|
81f4a7cdb7 | ||
|
|
c2a74d6d2a | ||
|
|
861e1b33f5 | ||
|
|
0647d56555 | ||
|
|
ea6aea8532 | ||
|
|
6eca2edd79 | ||
|
|
d31dfbc565 | ||
|
|
1e0f776824 | ||
|
|
db36f0105d | ||
|
|
5377e2400a | ||
|
|
72c0aa63fb | ||
|
|
933bee220f | ||
|
|
bd2dabfa8f | ||
|
|
f11b352089 | ||
|
|
bb54e60179 | ||
|
|
e52bdaa2a2 | ||
|
|
b6301c719b | ||
|
|
6e16c0699a | ||
|
|
bf4ad295af | ||
|
|
7a80e8fe77 | ||
|
|
1ec3512925 | ||
|
|
772ada4308 | ||
|
|
7165c8a7e5 | ||
|
|
daa1460502 | ||
|
|
f47c7ac369 | ||
|
|
7199813969 | ||
|
|
87d5fa516d | ||
|
|
10340d2a3f | ||
|
|
508c4d362f | ||
|
|
f73b008251 | ||
|
|
c583e64bb7 | ||
|
|
9df63b008d | ||
|
|
3daecc092c | ||
|
|
4d42811ecf | ||
|
|
1bebcf8033 | ||
|
|
45c555a4bd | ||
|
|
5986a83e80 | ||
|
|
732de4acf0 | ||
|
|
7400c0946e | ||
|
|
14ee2b2c11 | ||
|
|
c3e1b8cfd9 | ||
|
|
67a67df35a | ||
|
|
0f0578b268 | ||
|
|
662208949f | ||
|
|
e41821342b | ||
|
|
d3458a4fc3 | ||
|
|
32c91bbb25 | ||
|
|
aee13507f9 | ||
|
|
61b67f6301 | ||
|
|
b86619bcd0 | ||
|
|
31b5b45581 | ||
|
|
33cdb16b9e | ||
|
|
53fd7a4473 | ||
|
|
10d56d31e9 | ||
|
|
3633c829ae | ||
|
|
6cda84432e | ||
|
|
b914eaa6fa | ||
|
|
988b67aa30 | ||
|
|
0ed5b82389 | ||
|
|
b417fe5727 | ||
|
|
fabad7aa7a | ||
|
|
3c54da952a | ||
|
|
2ef2646b31 | ||
|
|
82ad7e29a6 | ||
|
|
2290a3c8af | ||
|
|
d216cebff5 | ||
|
|
05bd345828 | ||
|
|
5eff541da8 | ||
|
|
598a27cc96 | ||
|
|
08ce608ae7 | ||
|
|
928631309e | ||
|
|
971b98c96d | ||
|
|
a72da30c9a | ||
|
|
f7eabcb2d9 | ||
|
|
ac36eba822 | ||
|
|
6160521f2f | ||
|
|
ca9b0dbc88 | ||
|
|
11c7e05f43 | ||
|
|
1781105438 | ||
|
|
632ca01fbf | ||
|
|
b8fd22bfd8 | ||
|
|
98a1deb129 | ||
|
|
0c38f2df2a | ||
|
|
6bab813bb3 | ||
|
|
d8201f8436 | ||
|
|
b28e4e95c2 | ||
|
|
a3865f1417 | ||
|
|
fb10bf5f75 | ||
|
|
a9eb31e8fe | ||
|
|
3ec5ce8349 | ||
|
|
c5d70019bb | ||
|
|
a35fb3a9b4 | ||
|
|
79403f9083 | ||
|
|
7a44c19362 | ||
|
|
11fc10ea47 | ||
|
|
7e4e9ecdea | ||
|
|
0c013a237f | ||
|
|
f85951bc65 | ||
|
|
12e27f9e5e | ||
|
|
7e9be3c28c | ||
|
|
3368fcf31e | ||
|
|
32877afe55 | ||
|
|
efe7eca726 | ||
|
|
72d1fa4da5 | ||
|
|
2042013360 | ||
|
|
f5189cc897 | ||
|
|
75a9cd83a0 | ||
|
|
5684e2d658 | ||
|
|
2d28fa34f5 | ||
|
|
ea7d967625 | ||
|
|
5dfb2b1128 | ||
|
|
cbc599a5b8 | ||
|
|
1354d0836f | ||
|
|
b313250638 | ||
|
|
e37c147ea9 | ||
|
|
feb4f9028d | ||
|
|
4804ce5678 | ||
|
|
001a342f20 | ||
|
|
fe040b84d9 | ||
|
|
0ac30afb29 | ||
|
|
59601eb99c | ||
|
|
9616f4b2b1 | ||
|
|
9dd613edf7 | ||
|
|
88ed58b3d0 | ||
|
|
fc54e905c0 | ||
|
|
d1b76cb1b2 | ||
|
|
2c92ccd66e | ||
|
|
a9ff03acaf | ||
|
|
281dc10b2f | ||
|
|
fd32fc8d8d | ||
|
|
47f4f59692 | ||
|
|
5cf1a9535e | ||
|
|
e93102b276 | ||
|
|
da57c314ef | ||
|
|
2676636316 | ||
|
|
f3a973dc9e | ||
|
|
f4a1190bdd | ||
|
|
118a6d7421 | ||
|
|
4541bb2716 | ||
|
|
505c4262c6 | ||
|
|
3104b088e4 | ||
|
|
f12f814816 | ||
|
|
3b0ad719c9 | ||
|
|
e368e56246 | ||
|
|
675420013d | ||
|
|
eaa69fb6b2 | ||
|
|
e0795cf18c | ||
|
|
8ed878e73c | ||
|
|
08b95411df | ||
|
|
460fafff7f | ||
|
|
7b4fa9e1a1 | ||
|
|
7e4ebb22a0 | ||
|
|
8b47315845 | ||
|
|
96a5e01878 | ||
|
|
5e36390a27 | ||
|
|
729a545173 | ||
|
|
488f5e2dac | ||
|
|
49e89cf3f1 | ||
|
|
43f6b9ef32 | ||
|
|
8e48cffe3b | ||
|
|
3ed01adabc | ||
|
|
4239de8060 | ||
|
|
cba37f99b6 | ||
|
|
74db53d939 | ||
|
|
2b34bf08da | ||
|
|
b92f70c52b | ||
|
|
34d2e1e2e8 | ||
|
|
5f82739e2b | ||
|
|
d79dc4d742 | ||
|
|
1d12a844c2 | ||
|
|
2d16450869 | ||
|
|
2a6248dad6 | ||
|
|
8b27c03472 | ||
|
|
baf3bea574 | ||
|
|
868b438e67 | ||
|
|
8989bd9fd7 | ||
|
|
a4f12babb7 | ||
|
|
97e06a8eb4 | ||
|
|
0de6e38ce9 | ||
|
|
314164fb8a | ||
|
|
8d925226cb | ||
|
|
f2eb2004aa | ||
|
|
bf37015c23 | ||
|
|
f489b6e7a5 | ||
|
|
921e5be8e6 | ||
|
|
a8bc974a2e | ||
|
|
100a022ab7 | ||
|
|
6b7484a885 | ||
|
|
8de40e0c10 | ||
|
|
9b3aef3567 | ||
|
|
25e52e19dc | ||
|
|
68806902ff | ||
|
|
ebf8649940 | ||
|
|
c93d02891a | ||
|
|
87be5c737c | ||
|
|
ad9d6f616d | ||
|
|
f57f892409 | ||
|
|
5ecb65cbbe | ||
|
|
1e04481aaf | ||
|
|
5f103e32bd | ||
|
|
fff9efe8a8 | ||
|
|
b135b3efb9 | ||
|
|
17e17f85ae | ||
|
|
ecef49605b | ||
|
|
7b72b35cca | ||
|
|
16420e5b31 | ||
|
|
55665246bb | ||
|
|
b9b862a380 | ||
|
|
0766c5e3cb | ||
|
|
8d4c6d41ab | ||
|
|
58d32d4542 | ||
|
|
6bad75827a | ||
|
|
2b3ddabe90 | ||
|
|
e92b480629 | ||
|
|
a53cdbf1b4 | ||
|
|
21a64a9957 | ||
|
|
d656db4d04 | ||
|
|
506b66a852 | ||
|
|
95f03d63ad | ||
|
|
7f8af736dd | ||
|
|
eaacebeecc | ||
|
|
fd4cff06ca | ||
|
|
b50df6eb1d | ||
|
|
fa16304e4f | ||
|
|
eda74d3a55 | ||
|
|
25762c0ac6 | ||
|
|
2d7289bcad | ||
|
|
2d1d5d603d | ||
|
|
94206cf10f | ||
|
|
dc2521a1cf | ||
|
|
30b5955f22 | ||
|
|
4267a1b87d | ||
|
|
eb44ae76f1 | ||
|
|
bd3d18f660 | ||
|
|
8bd5f1b9f2 | ||
|
|
71b0dcc922 | ||
|
|
1bf7d2f3bd | ||
|
|
87127fd133 | ||
|
|
e85c15d178 | ||
|
|
0f56dce748 | ||
|
|
d2e2077ada | ||
|
|
9adbf47773 | ||
|
|
e5ee041d4e | ||
|
|
63a46a85f6 | ||
|
|
fe87d6d8be | ||
|
|
9387ecf043 | ||
|
|
35582cfe8a | ||
|
|
76e24653e9 | ||
|
|
4c2812b429 | ||
|
|
7154bc6857 | ||
|
|
c31070db24 | ||
|
|
336048441c | ||
|
|
cbac34347b | ||
|
|
3ee27a00c7 | ||
|
|
4ec020a86d | ||
|
|
464dabdc16 | ||
|
|
c0976ec099 | ||
|
|
7f3113b8d4 | ||
|
|
9180cbe821 | ||
|
|
c5daa754ff | ||
|
|
23a29216d3 | ||
|
|
8a2168ecf6 | ||
|
|
38d8a669b4 | ||
|
|
06e379a239 | ||
|
|
7c0379ce05 | ||
|
|
c7c13f2d5e | ||
|
|
6df9b3f38c | ||
|
|
ca81d94b90 | ||
|
|
a39ef7181d | ||
|
|
93b7e3431b | ||
|
|
dd02cc0747 | ||
|
|
867883453e | ||
|
|
a68784c319 | ||
|
|
46c763410f | ||
|
|
815d4572f6 | ||
|
|
279a191b86 | ||
|
|
f0da42917b | ||
|
|
6e87fd2d4c | ||
|
|
fbf5efb570 | ||
|
|
1a3323a261 | ||
|
|
b858fdd755 | ||
|
|
0aff827414 | ||
|
|
bd8a0a9f8f | ||
|
|
73d0e2cb81 | ||
|
|
47f816696c | ||
|
|
1cf455e91c | ||
|
|
952c8c2d64 | ||
|
|
dce3bf01fd | ||
|
|
7b1687d7e5 | ||
|
|
9ad6863567 | ||
|
|
4c1424bb83 | ||
|
|
c7364de2f0 | ||
|
|
e0043906be | ||
|
|
eda9fb5522 | ||
|
|
8a775144bf | ||
|
|
9b65534561 | ||
|
|
f6c0618596 | ||
|
|
15fd030fa4 | ||
|
|
693be03dcc | ||
|
|
6e3cb34024 | ||
|
|
bd7cd33b02 | ||
|
|
a72fdf7c26 | ||
|
|
850cbfe369 | ||
|
|
351db0632d | ||
|
|
d642e90cdd | ||
|
|
c454f7ac0d | ||
|
|
b5b47d7273 | ||
|
|
7c2c541729 | ||
|
|
f10abc8ee0 | ||
|
|
8ea27968d7 | ||
|
|
956db9c182 | ||
|
|
3eb3f38adf | ||
|
|
35b66e5ad1 | ||
|
|
d83ea305b5 | ||
|
|
c1d8508748 | ||
|
|
dc8f2bda2a | ||
|
|
f0f5acfa42 | ||
|
|
4e00edf8a7 | ||
|
|
02d5c00873 | ||
|
|
17009d28cf | ||
|
|
325a6a4e02 | ||
|
|
b51b24955c | ||
|
|
a954aaa507 | ||
|
|
ad475239a5 | ||
|
|
5e280674f9 | ||
|
|
6cdfd143b0 | ||
|
|
da454fa376 | ||
|
|
358dd4f791 | ||
|
|
2401abe17e | ||
|
|
56ea6b6e43 | ||
|
|
04691ed598 | ||
|
|
7366b55b14 | ||
|
|
a248bea50f | ||
|
|
c8c84bc419 | ||
|
|
5f990fb3a2 | ||
|
|
538c1eb660 | ||
|
|
9f704d7aa7 | ||
|
|
a5777300d8 | ||
|
|
57e1362344 | ||
|
|
c1ccbd58f5 | ||
|
|
09a2ab420b | ||
|
|
596770942a | ||
|
|
fe5e58af91 | ||
|
|
0a4c2f91f5 | ||
|
|
5b33a7dcbe | ||
|
|
c7e2b1230c | ||
|
|
bdf6a23de9 | ||
|
|
1a539b9830 | ||
|
|
3addd3420b | ||
|
|
6ea10dd153 | ||
|
|
bf0bee58b3 | ||
|
|
fbcbc60e85 | ||
|
|
0a9f06d60f | ||
|
|
f6956320f9 | ||
|
|
20bc323963 | ||
|
|
bcead5f0f4 | ||
|
|
cf3049ae34 | ||
|
|
ad9a9d8d35 | ||
|
|
14e9077584 | ||
|
|
43cf526b5f | ||
|
|
2d5c401d11 | ||
|
|
78cf68549f | ||
|
|
dececccd8e | ||
|
|
941ad27551 | ||
|
|
24e95ab38e | ||
|
|
c4de0b8255 | ||
|
|
7baaca4a76 | ||
|
|
ea248f6743 | ||
|
|
f03605d8ae | ||
|
|
0babf08926 | ||
|
|
6517b05abe | ||
|
|
fa91b5fd03 | ||
|
|
f831ccfc63 | ||
|
|
12084fc4f9 | ||
|
|
21237dae98 | ||
|
|
4bdc25d072 | ||
|
|
2f55abace2 | ||
|
|
3213e5df2d | ||
|
|
7e40147aa3 | ||
|
|
a2a26b26fb | ||
|
|
b3cf07d6cb | ||
|
|
ed76cd7574 | ||
|
|
01b8a71ee6 | ||
|
|
cc86bbf27d | ||
|
|
42cbb11de8 | ||
|
|
52303e8eda | ||
|
|
cf903be4a7 | ||
|
|
6306786645 | ||
|
|
d7b267843e | ||
|
|
3aefe375c1 | ||
|
|
3d6cc435ef | ||
|
|
973bd3a427 | ||
|
|
7d1ec51df5 | ||
|
|
9fb74399c8 | ||
|
|
bc0a6fffd1 | ||
|
|
fa85dd6527 | ||
|
|
73d595eecc | ||
|
|
3bf8b9ccf4 | ||
|
|
83262a67b1 | ||
|
|
66952a682d | ||
|
|
9df22c0093 | ||
|
|
27adfb76fa | ||
|
|
9c532eac07 | ||
|
|
2814815312 | ||
|
|
ab27586674 | ||
|
|
2749c5cac3 | ||
|
|
715cf311df | ||
|
|
312443235d | ||
|
|
0d95d63258 | ||
|
|
f86772f26c | ||
|
|
a7617e4d79 | ||
|
|
7612a83fa2 | ||
|
|
afbd18e8df | ||
|
|
be2bc61d38 | ||
|
|
dcee8beb99 | ||
|
|
fb8f72d5a9 | ||
|
|
b3f2416a09 | ||
|
|
b5ae2ccc3c | ||
|
|
05efc3eace | ||
|
|
24f8ff7548 | ||
|
|
c0c6782a17 | ||
|
|
d2ac672f47 | ||
|
|
e3d8d5f300 | ||
|
|
c5d5c9fcb5 | ||
|
|
2e040ee07a | ||
|
|
9846c46434 | ||
|
|
5c7c1af44e | ||
|
|
e119a82334 | ||
|
|
02db68aa67 | ||
|
|
10e1e7fd44 | ||
|
|
7aabe73521 | ||
|
|
37f85bb2d1 | ||
|
|
39fccc3699 | ||
|
|
53eccc1c1e | ||
|
|
c56292a6ec | ||
|
|
857cd6a28a | ||
|
|
303954ae8c | ||
|
|
3c338d1858 | ||
|
|
20d7882033 | ||
|
|
6927b0fb8d | ||
|
|
6e83f95c83 | ||
|
|
8f0c8a6561 | ||
|
|
a61b7056d5 | ||
|
|
f41ade9417 | ||
|
|
b0396e196f | ||
|
|
cf42fabfd8 | ||
|
|
52263bd5a3 | ||
|
|
24151a2028 | ||
|
|
c11e2d9e5e | ||
|
|
a8c9b2810b | ||
|
|
7a849ab7d1 | ||
|
|
c14d738d37 | ||
|
|
65478a6ff3 | ||
|
|
41be9232fe | ||
|
|
653932e50d | ||
|
|
09ef991e1a | ||
|
|
0f7029583c | ||
|
|
10eced9971 | ||
|
|
1d8b47785c | ||
|
|
ced271bec1 | ||
|
|
5d19afd422 | ||
|
|
b7363f7c18 | ||
|
|
aa2700ffa7 | ||
|
|
510e2a1d17 | ||
|
|
ebfe55f909 | ||
|
|
26fa9dea97 | ||
|
|
3bb4c0c237 | ||
|
|
255a875a2a | ||
|
|
2b5f3f1361 | ||
|
|
eb158545fc | ||
|
|
cade7b1132 | ||
|
|
d529736597 | ||
|
|
8dfc031c4d | ||
|
|
91c9859000 | ||
|
|
3a485a14a4 | ||
|
|
a61c27c4d0 | ||
|
|
e5cae2a2e4 | ||
|
|
7f961237f9 | ||
|
|
69a6538567 | ||
|
|
5b3c18ab84 | ||
|
|
907371453d | ||
|
|
81abffd145 | ||
|
|
44ef8fe5c8 | ||
|
|
cae78b3f91 | ||
|
|
c0fb814658 | ||
|
|
7ce0140c81 | ||
|
|
12b3034921 | ||
|
|
ec482ac867 | ||
|
|
ae52fb7a01 | ||
|
|
e8ff08e121 | ||
|
|
cc8e104cd6 | ||
|
|
5919a277bb | ||
|
|
96911d7790 | ||
|
|
acd3f7dba7 | ||
|
|
8aff3979db | ||
|
|
eafcd862be | ||
|
|
8826170635 | ||
|
|
c54e4d0900 | ||
|
|
52ca5c4aa2 | ||
|
|
95f8f80e74 | ||
|
|
7e380bb6f8 | ||
|
|
2477ffd860 | ||
|
|
a3dc46bf9d | ||
|
|
5c8e1b6eef | ||
|
|
ae9a8ce34c | ||
|
|
67b9a675f5 | ||
|
|
fae11e5a55 | ||
|
|
4daf75a469 | ||
|
|
d0293649cd | ||
|
|
353366ac54 | ||
|
|
1a8ffebb00 | ||
|
|
5ffbddcc57 | ||
|
|
5fbcbe7e52 | ||
|
|
7daa93cf5a | ||
|
|
9e32f29d19 | ||
|
|
1f25e38c2d | ||
|
|
c10a386d17 | ||
|
|
a13db82d28 | ||
|
|
ec392dc870 | ||
|
|
90d00fb095 | ||
|
|
e336b7f27e | ||
|
|
7f4c992dd7 | ||
|
|
ba1626a5b9 | ||
|
|
ab73c40bfe | ||
|
|
4016bc2416 | ||
|
|
9302daadc1 | ||
|
|
de7429e148 | ||
|
|
5892bd45d8 | ||
|
|
9317eccfc8 | ||
|
|
1236c4dafb | ||
|
|
f50f18f65a | ||
|
|
747cc4daa5 | ||
|
|
51b6a785e6 | ||
|
|
f4d41ef254 | ||
|
|
b9d80aa535 | ||
|
|
2f8213ca9a | ||
|
|
541b8cbb6c | ||
|
|
ed2e738ea4 | ||
|
|
17d9ba256b | ||
|
|
15dbac8193 | ||
|
|
2119854246 | ||
|
|
034c93fd65 | ||
|
|
ce91aba4de | ||
|
|
e33c09f8d4 | ||
|
|
a678c3f53e | ||
|
|
3e4fc7ff7f | ||
|
|
8dda07a1e9 | ||
|
|
e9f1851c5d | ||
|
|
ac659ff5a7 | ||
|
|
557f8e5a04 | ||
|
|
54de5ad3fa | ||
|
|
0709586e3a | ||
|
|
82ced33747 | ||
|
|
d31c5d7a2c | ||
|
|
2045487d5e | ||
|
|
4611e799b7 | ||
|
|
ffe9a2435b | ||
|
|
f5d8876384 | ||
|
|
d28265cfbe | ||
|
|
8059e83c49 | ||
|
|
d6f07c9f91 | ||
|
|
917cb8fa67 | ||
|
|
461db9e469 | ||
|
|
112908886c | ||
|
|
f734801da1 | ||
|
|
ea6dc7c710 | ||
|
|
cd81348ca5 | ||
|
|
ad91a09b07 | ||
|
|
040f73a3f4 | ||
|
|
0d8e0ddc4f | ||
|
|
8f9d7405ed | ||
|
|
72267e97ca | ||
|
|
19f87f0a89 | ||
|
|
9f7b1f0942 | ||
|
|
1ef888ca23 | ||
|
|
8b815bce94 | ||
|
|
97539db36d | ||
|
|
655fa5b8e0 | ||
|
|
9fbd3cc16f | ||
|
|
2295cbb815 | ||
|
|
198f8ea700 | ||
|
|
9fa9199747 | ||
|
|
1cd167a59a | ||
|
|
2868dc975c | ||
|
|
214ab16eb2 | ||
|
|
1c88d9575e | ||
|
|
1e4e02ddd3 | ||
|
|
f6fcddbe0b | ||
|
|
474180c112 | ||
|
|
c860573f13 | ||
|
|
c9c7354009 | ||
|
|
42eb7640f9 | ||
|
|
aafcd569b1 | ||
|
|
b549307ccf | ||
|
|
57090d4f8d | ||
|
|
764f7586de | ||
|
|
d96f2abc4e | ||
|
|
92f467e81c | ||
|
|
2442186a31 | ||
|
|
9fb74cb58a | ||
|
|
81e11c1d91 | ||
|
|
dc93350e0a | ||
|
|
3c6432da1f | ||
|
|
4eecb6841a | ||
|
|
3b83d3ff3a | ||
|
|
88b92a9605 | ||
|
|
3bb5baa6d2 | ||
|
|
59443d7ec6 | ||
|
|
c1d170e13d | ||
|
|
cffac6e11a | ||
|
|
79870472e1 | ||
|
|
1b69c94f76 | ||
|
|
cf8d1cf0e7 | ||
|
|
009fbeb543 | ||
|
|
9ceb8731d3 | ||
|
|
8f934bf817 | ||
|
|
88be2701f4 | ||
|
|
8ee62f0ac8 | ||
|
|
4d4308af78 | ||
|
|
f7c5eff35e | ||
|
|
3bc1644f34 | ||
|
|
27025b71db | ||
|
|
523d9ec3c2 | ||
|
|
aeb5455555 | ||
|
|
337390b590 | ||
|
|
836d950e05 | ||
|
|
ad096f77fc | ||
|
|
3774494f7e | ||
|
|
14fae5af9e | ||
|
|
65b48561a9 | ||
|
|
842dc14c18 | ||
|
|
af1afa7ba6 | ||
|
|
8c4c5e524b | ||
|
|
204bd7d2c4 | ||
|
|
f44014ff00 | ||
|
|
01719b02e2 | ||
|
|
4ba86bbe00 | ||
|
|
b85503b3b2 | ||
|
|
131a9aa1ac | ||
|
|
bd223606b1 | ||
|
|
f4fb80e523 | ||
|
|
49e466dd40 | ||
|
|
deec315f6a | ||
|
|
7fafe54e16 | ||
|
|
bdcbc829a0 | ||
|
|
4a64e86ecb | ||
|
|
1e2946ebc6 | ||
|
|
1ed5ca3fde | ||
|
|
aa62ac4042 | ||
|
|
e8f24910bd | ||
|
|
8d34e54dc5 | ||
|
|
c5ede3f167 | ||
|
|
1cd108e891 | ||
|
|
8878fd3028 | ||
|
|
a22d4e7962 | ||
|
|
25d2d7389f | ||
|
|
816b784399 | ||
|
|
c250f092bb | ||
|
|
b9c2bdf641 | ||
|
|
5ba90db049 | ||
|
|
88d20c5419 | ||
|
|
e158bee95f | ||
|
|
0139a77e94 | ||
|
|
e76d1b899b | ||
|
|
3fcdd6c9d7 | ||
|
|
bc916dbf35 | ||
|
|
96da2efb13 | ||
|
|
267cdf20e1 | ||
|
|
20c7df35c4 | ||
|
|
0f06e9926b | ||
|
|
93af424ce5 | ||
|
|
5e07400cd1 | ||
|
|
364a6a9444 | ||
|
|
b6bfd8e34f | ||
|
|
b05981ef27 | ||
|
|
42f1a56832 | ||
|
|
f667d56701 | ||
|
|
df5284beaf | ||
|
|
6d551b0d6e | ||
|
|
25e6339e2e | ||
|
|
f70fd30cd3 | ||
|
|
863d26558a | ||
|
|
cba12a1abd | ||
|
|
96d57a18ee | ||
|
|
e54ed10bc1 | ||
|
|
c8c807adcc | ||
|
|
cd6ed79433 | ||
|
|
ea4b3b74bb | ||
|
|
facfd64787 | ||
|
|
760a83d256 | ||
|
|
bbff19698b | ||
|
|
6f38cb162c | ||
|
|
af82224f82 | ||
|
|
a938e9473b | ||
|
|
3e88553d52 | ||
|
|
56245d5646 | ||
|
|
4af08b1606 | ||
|
|
fc4a395c88 | ||
|
|
de1813ab32 | ||
|
|
89ace66972 | ||
|
|
63f1857bda | ||
|
|
279500cba4 | ||
|
|
183270b443 | ||
|
|
a5f4332f21 | ||
|
|
6fad79f581 | ||
|
|
dff6274a93 | ||
|
|
082c872469 | ||
|
|
67a3dda53a | ||
|
|
950432eac0 | ||
|
|
6550e7d562 | ||
|
|
ffe75f3e20 | ||
|
|
8431874b15 | ||
|
|
54d2ccda99 | ||
|
|
926b6d9464 | ||
|
|
abfb6832c3 | ||
|
|
ceeea359fc | ||
|
|
ef35868bef | ||
|
|
cf48d297dd | ||
|
|
2b20e3d2b0 | ||
|
|
918cbdcf03 | ||
|
|
f5837dff9c | ||
|
|
ce04308c17 | ||
|
|
c0c20ebf3e | ||
|
|
823195a122 | ||
|
|
581583abb4 | ||
|
|
882fd48408 | ||
|
|
91238df13f | ||
|
|
ca806897c2 | ||
|
|
9118884e92 | ||
|
|
e403f8b620 | ||
|
|
6205b955da | ||
|
|
d265a04b19 | ||
|
|
afc09744b4 | ||
|
|
1e1d76d600 | ||
|
|
0b70aa0c56 | ||
|
|
4ca6591045 | ||
|
|
9717f2d374 | ||
|
|
469c8a1a4b | ||
|
|
9d47b15575 | ||
|
|
a11a204b8e | ||
|
|
e3c3d108fe | ||
|
|
8cadb5cf18 | ||
|
|
f10c8f2b4c | ||
|
|
5d2d701e1e | ||
|
|
f24d8473b1 | ||
|
|
3412ff7003 | ||
|
|
15e468f5dd | ||
|
|
a0dd504991 | ||
|
|
19b847b23b | ||
|
|
3b134c8fef | ||
|
|
c872f37aae | ||
|
|
3ce5b9b0d9 | ||
|
|
2d7c5f8c53 | ||
|
|
79c0fd27a0 | ||
|
|
b06d1ed072 | ||
|
|
52e7a4456a | ||
|
|
f1202ff152 | ||
|
|
e4db7cbd2b | ||
|
|
ff63204d17 | ||
|
|
4f3a3e93a9 | ||
|
|
b56d4b90ce | ||
|
|
6c2f9b3150 | ||
|
|
a808cdce13 | ||
|
|
a8629e1855 | ||
|
|
0146784e18 | ||
|
|
249b85af1e | ||
|
|
efc12ab28d | ||
|
|
5b2e7d4464 | ||
|
|
bcd3c13e2c | ||
|
|
7932e966db | ||
|
|
30d84643db | ||
|
|
264c91e620 | ||
|
|
db89be4106 | ||
|
|
85816a5ee2 | ||
|
|
5449e44381 | ||
|
|
20630b8744 | ||
|
|
3b63d1cb77 | ||
|
|
5703b9e737 | ||
|
|
02787b5674 | ||
|
|
4021da524c | ||
|
|
5adec0eae0 | ||
|
|
3f44f0b753 | ||
|
|
2a975f751b | ||
|
|
03bd049291 | ||
|
|
6ddd36666e | ||
|
|
3791db006e | ||
|
|
6bf8c0c17a | ||
|
|
80e1934f4e | ||
|
|
7415fdb79b | ||
|
|
b850b0dacf | ||
|
|
04e3d0c2fe | ||
|
|
3810519671 | ||
|
|
a08c8ef1fa | ||
|
|
6496a288b8 | ||
|
|
9f72eb3374 | ||
|
|
e71c71c6c2 | ||
|
|
0197fb35fe | ||
|
|
bcc5891e03 | ||
|
|
f90ab3c4c2 | ||
|
|
79280f3d93 | ||
|
|
ce79d0b9a4 | ||
|
|
a5b4a01594 | ||
|
|
5b25eeb449 | ||
|
|
fb259e8a50 | ||
|
|
b82dfe08a2 | ||
|
|
4671c9e672 | ||
|
|
00cdcd4d28 | ||
|
|
4e1fe88195 | ||
|
|
28ad475ab4 | ||
|
|
104e265633 | ||
|
|
382d237a60 | ||
|
|
de2fd659ab | ||
|
|
d2fda411f3 | ||
|
|
e02944c323 | ||
|
|
a01f4998c5 | ||
|
|
aa198594fd | ||
|
|
406a94bf76 | ||
|
|
fef1841fee | ||
|
|
1cb85fdea8 | ||
|
|
78263e81f1 | ||
|
|
053c8d5731 | ||
|
|
d69064f364 | ||
|
|
fedb24caf1 | ||
|
|
6ff8371254 | ||
|
|
7b6eaa819e | ||
|
|
e94aa296e2 | ||
|
|
98891103d0 | ||
|
|
383097a03a | ||
|
|
2b2f13ca79 | ||
|
|
78159a9435 | ||
|
|
b4af7b919e | ||
|
|
65056915d3 | ||
|
|
bc3f744e45 | ||
|
|
fb8da15b01 | ||
|
|
62f624b66b | ||
|
|
ef20053e72 | ||
|
|
aae68e4f82 | ||
|
|
1d715d7b1b | ||
|
|
1d7110ea8f | ||
|
|
80f70a58e3 | ||
|
|
f7aabeba04 | ||
|
|
02f6cac9d6 | ||
|
|
df54fc6098 | ||
|
|
fe0fb8d296 | ||
|
|
591120a7f7 | ||
|
|
878f074494 | ||
|
|
c1050da852 | ||
|
|
873daf079c | ||
|
|
df9e4bdd63 | ||
|
|
43ba1671f1 | ||
|
|
ce4b68d5fb | ||
|
|
8c18dd40a3 | ||
|
|
e3015bbfb7 | ||
|
|
817abd8b5f | ||
|
|
dbc9b00de5 | ||
|
|
b635e83651 | ||
|
|
7aeacdcc6c | ||
|
|
16e4a0c4bd | ||
|
|
d613800516 | ||
|
|
94b89216f7 | ||
|
|
153e09120a | ||
|
|
238c0c1b86 | ||
|
|
98ff213708 | ||
|
|
8a2a07eddb | ||
|
|
9076d543f3 | ||
|
|
cd77dc9563 | ||
|
|
9ccf80848d | ||
|
|
78cb565dc2 | ||
|
|
6a30452b4a | ||
|
|
e53442d983 | ||
|
|
bc079b29c3 | ||
|
|
cd6addd742 | ||
|
|
12d6e1cddd | ||
|
|
28e5ebd72b | ||
|
|
e8106109e3 | ||
|
|
c71d5a8a77 | ||
|
|
d1d27a0bd6 | ||
|
|
ebb7428479 | ||
|
|
3163a42f36 | ||
|
|
35a25c3dc2 | ||
|
|
f34f374179 | ||
|
|
aa330350fc | ||
|
|
a2cf1f98d9 | ||
|
|
f84def1b60 | ||
|
|
91d4c24078 | ||
|
|
8fe0b72a04 | ||
|
|
2bcdf741f9 | ||
|
|
9ae73e87eb | ||
|
|
77582ff5d4 | ||
|
|
52a2dfe08b | ||
|
|
09d2165d36 | ||
|
|
fb9c1f7e65 | ||
|
|
abf05af474 | ||
|
|
714ba2a58d | ||
|
|
405ff0377a | ||
|
|
8421ef7b4a | ||
|
|
fd151c4fc6 | ||
|
|
b36b20d246 | ||
|
|
44ffe41775 | ||
|
|
2ca7c2629c | ||
|
|
483c0e4cea | ||
|
|
7d51bf0eb0 | ||
|
|
ab4457e2a3 | ||
|
|
1eb6d617f5 | ||
|
|
21ac34bc6a | ||
|
|
c050a82c3a | ||
|
|
750408d0a2 | ||
|
|
a44a313f77 | ||
|
|
d159602928 | ||
|
|
50e817f193 | ||
|
|
929a10e33d | ||
|
|
c38aeb1081 | ||
|
|
35e0894655 | ||
|
|
943f0d475f | ||
|
|
96cbab2b22 | ||
|
|
36c85a617a | ||
|
|
1356498ee1 | ||
|
|
49ec53f4ae | ||
|
|
5687a03f0b | ||
|
|
cdb2a0736a | ||
|
|
cfd3efb6e7 | ||
|
|
8ec0d813c0 | ||
|
|
ea5333e5f7 | ||
|
|
b13723d3d7 | ||
|
|
03a4e0c837 | ||
|
|
f49c20c508 | ||
|
|
d3821123ee | ||
|
|
759ab8acbc | ||
|
|
7a88071a16 | ||
|
|
f3c4d1a181 | ||
|
|
4e491757ef | ||
|
|
5936ed7941 | ||
|
|
6b56f7d643 | ||
|
|
e618a21f4e | ||
|
|
0f271ab535 | ||
|
|
4c054917ef | ||
|
|
b9eabe532e | ||
|
|
4ee292a952 | ||
|
|
adc2900aff | ||
|
|
9c801e9c08 | ||
|
|
ba0791b896 | ||
|
|
c4a67b7d02 | ||
|
|
bd572c775d | ||
|
|
65329496a7 | ||
|
|
2288ec7384 | ||
|
|
80b3b9e00c | ||
|
|
3876c1679a | ||
|
|
ba85f4a62a | ||
|
|
a1b34ef0ef | ||
|
|
f03d2d1b33 | ||
|
|
c7048973bb | ||
|
|
e800e84a77 | ||
|
|
d306fcb8a2 | ||
|
|
44339a6447 | ||
|
|
675aadc6a9 | ||
|
|
d95c09d94a | ||
|
|
f508fd3fa2 | ||
|
|
cf96ad8ef9 | ||
|
|
066a2828c4 | ||
|
|
b6c11154ae | ||
|
|
6ca897e055 | ||
|
|
23ffa1905a | ||
|
|
a88e5968ae | ||
|
|
4abaf62783 | ||
|
|
9bf5b92d8f | ||
|
|
044f525eb8 | ||
|
|
554d9bc6ce | ||
|
|
49654803aa | ||
|
|
44c951e432 | ||
|
|
e1b8c30163 | ||
|
|
70faa4ff36 | ||
|
|
63b63cd66d | ||
|
|
137980b46e | ||
|
|
055d839fc3 | ||
|
|
3e39dd49aa | ||
|
|
082b4fb193 | ||
|
|
de1f119a7d | ||
|
|
7ce12863b8 | ||
|
|
1ab69948a5 | ||
|
|
13298d84ea | ||
|
|
c2c5b28c70 | ||
|
|
6e200ed1c0 | ||
|
|
3fadbb29a1 | ||
|
|
6e4eef4a49 | ||
|
|
8feb09aa89 | ||
|
|
e1a3bab7e5 | ||
|
|
e0cd5650c5 | ||
|
|
80c09f0845 | ||
|
|
1f831c6037 | ||
|
|
cc0075e988 | ||
|
|
4b44a75bc1 | ||
|
|
f46beec20d | ||
|
|
973bf67683 | ||
|
|
ff6a918e7e | ||
|
|
5ef2666127 | ||
|
|
ed001a5f55 | ||
|
|
13ebbd1a2b | ||
|
|
ca8e556619 | ||
|
|
8900c84155 | ||
|
|
002d927874 | ||
|
|
cef5bf2768 | ||
|
|
529543b36d | ||
|
|
636e4d38d5 | ||
|
|
2d8e11b78b | ||
|
|
0e2993a6c8 | ||
|
|
f0ebad3f21 | ||
|
|
a02adcc2ef | ||
|
|
d1850aaada | ||
|
|
cf21a15e06 | ||
|
|
13124542cf | ||
|
|
cd5809d11f | ||
|
|
28938ddb32 | ||
|
|
3c551fd36f | ||
|
|
94c495c8ed | ||
|
|
f54c801bd2 | ||
|
|
429972b5c5 | ||
|
|
9b8a4d0c76 | ||
|
|
235f3ce0ba | ||
|
|
06806a1ea1 | ||
|
|
b1a85d89d2 | ||
|
|
6fc30962d6 | ||
|
|
849446ae17 | ||
|
|
479720c169 | ||
|
|
0e94c6b025 | ||
|
|
1a51257b71 | ||
|
|
4e74ba996d | ||
|
|
80a87e5f9e | ||
|
|
a526d3c1f2 | ||
|
|
d67bec0740 | ||
|
|
b2e11c504b | ||
|
|
1b38ee8b46 | ||
|
|
afa4a234f9 | ||
|
|
46b9006de2 | ||
|
|
d54ecc3961 | ||
|
|
fa54950d2e | ||
|
|
0ac7a93c28 | ||
|
|
bc2a66da32 | ||
|
|
bcced90f11 | ||
|
|
eb076165d2 | ||
|
|
9248919b05 | ||
|
|
5472589ddd | ||
|
|
19f5183176 | ||
|
|
beb6e25ef0 | ||
|
|
0ad49c25aa | ||
|
|
d46823333d | ||
|
|
836f645621 | ||
|
|
96be450cbb | ||
|
|
56cb415509 | ||
|
|
2ef2136c2c | ||
|
|
0b16b4481a | ||
|
|
0b18f1b948 | ||
|
|
a4d4a30a6b | ||
|
|
98bbc73925 | ||
|
|
bb7f4abd4b | ||
|
|
bd63b5a231 | ||
|
|
590f3d0e8f | ||
|
|
6cbfa01176 | ||
|
|
f929e1b105 | ||
|
|
77104395ce | ||
|
|
c0d5853c63 | ||
|
|
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 | ||
|
|
12d9a13af0 | ||
|
|
164841f299 | ||
|
|
778361686c | ||
|
|
29907a4c3f | ||
|
|
81f38342bf | ||
|
|
36b93c8dc7 | ||
|
|
e95fdbbc37 | ||
|
|
3001f115b6 | ||
|
|
21649d81d2 | ||
|
|
5118ba3dd2 | ||
|
|
f9409cbe43 | ||
|
|
572d17f46b | ||
|
|
f466f1bf46 | ||
|
|
594315d90b | ||
|
|
f84895f1f1 | ||
|
|
952810f76c | ||
|
|
73ccbedcdb | ||
|
|
7ef83311bb | ||
|
|
416c376077 | ||
|
|
ef83a07066 | ||
|
|
ae0c1573fd | ||
|
|
378e5acd23 | ||
|
|
a56daa6c06 | ||
|
|
84399e62ae | ||
|
|
387615e99f | ||
|
|
f98ab2d037 | ||
|
|
19ce08b4d0 | ||
|
|
8cc2dc715c | ||
|
|
ca20a2dc06 | ||
|
|
f9b1a96c89 | ||
|
|
854f07d735 | ||
|
|
7f4f01009b | ||
|
|
117b01acbd | ||
|
|
2b38ddf78d | ||
|
|
5e51107711 | ||
|
|
3bb33bdeed | ||
|
|
9b9fa009d1 | ||
|
|
072ad8d371 | ||
|
|
8846ffec64 | ||
|
|
3b72ed6e1a | ||
|
|
35b7c0f558 | ||
|
|
d5d80f4247 | ||
|
|
e915ed182d | ||
|
|
c3aed2543e | ||
|
|
e502ad13f9 | ||
|
|
952d924581 | ||
|
|
0484aba892 | ||
|
|
03c84d0f11 | ||
|
|
086f98471e | ||
|
|
cc4f0d8acc | ||
|
|
c7bd4b5c1d | ||
|
|
14e3b34a8e | ||
|
|
6354dddff2 | ||
|
|
c50c3699d9 | ||
|
|
6a7f955818 | ||
|
|
9cf457be0a | ||
|
|
e31383a8f1 | ||
|
|
13b8dc61ba | ||
|
|
61085f6141 | ||
|
|
d8cb1daa78 | ||
|
|
de2e341947 | ||
|
|
e944a0239d | ||
|
|
ce8db12b22 | ||
|
|
1d41129b6c | ||
|
|
6d6c3ad2c4 | ||
|
|
0b532579d8 | ||
|
|
b9007dc721 | ||
|
|
211efffa10 | ||
|
|
e3b50b7d12 | ||
|
|
aae49f1d68 | ||
|
|
6b4141247e | ||
|
|
327f6e7e25 | ||
|
|
296c0a6b70 | ||
|
|
356b6e0483 | ||
|
|
08a473fb35 | ||
|
|
893eef846d | ||
|
|
4ecd35c275 | ||
|
|
27a7d9f9d1 | ||
|
|
c0abab226d | ||
|
|
f1320b79ce | ||
|
|
bf41197b97 | ||
|
|
3f7fcad9ac | ||
|
|
e2ad0ed9f7 | ||
|
|
d2158966db | ||
|
|
8086c66ab8 | ||
|
|
7d37195c1a | ||
|
|
241cf10bdb | ||
|
|
337ae05ed8 | ||
|
|
378e39d7ad | ||
|
|
8fb3aef917 | ||
|
|
c86cb4e9a5 | ||
|
|
8ca240fb2c | ||
|
|
9ea697ac09 | ||
|
|
37eaa49e4c | ||
|
|
e64ca7c583 | ||
|
|
62a7a07127 | ||
|
|
bc618ec290 | ||
|
|
0780859a4d | ||
|
|
79818f73c0 | ||
|
|
957d7fbe2a | ||
|
|
6e9d3092a7 | ||
|
|
7dab927260 | ||
|
|
c417517f43 | ||
|
|
fd0314a6bd | ||
|
|
6a05d60f41 | ||
|
|
cd84c5ad08 | ||
|
|
b0187d7f28 | ||
|
|
7a1d64fff9 | ||
|
|
debcf19199 | ||
|
|
ea76425f86 | ||
|
|
88936b6216 | ||
|
|
e6edcd9a7f | ||
|
|
af78762421 | ||
|
|
bf159bd316 | ||
|
|
9eda40234f | ||
|
|
00336f554f | ||
|
|
a524b9ae9b | ||
|
|
3f1bcac077 | ||
|
|
679ced7840 | ||
|
|
7422f54212 | ||
|
|
b0384d0335 | ||
|
|
6b64039fcb | ||
|
|
19e7c708ce | ||
|
|
c8ca5803fc | ||
|
|
5f48abb451 | ||
|
|
491fd6b74d | ||
|
|
f1ff24d634 | ||
|
|
0242383ec3 | ||
|
|
768887fc0f | ||
|
|
958c13e02d | ||
|
|
f417b51fb6 | ||
|
|
47a1f757a9 | ||
|
|
27ad3b917f | ||
|
|
93a5784c58 | ||
|
|
e9fd73141d | ||
|
|
6c005b3d35 | ||
|
|
2967bc5988 | ||
|
|
55772eec5a | ||
|
|
c2adda1cfe | ||
|
|
51d77aea2e | ||
|
|
8f456ea73b | ||
|
|
6459582952 | ||
|
|
3796882d22 | ||
|
|
4db69c8eac | ||
|
|
f6a86e5527 | ||
|
|
063b35f1dc | ||
|
|
b61147aed0 | ||
|
|
fd3516bc82 | ||
|
|
5e4a91f996 | ||
|
|
df4331da04 | ||
|
|
fe3a983d35 | ||
|
|
53c349cb86 | ||
|
|
8f37f15a33 | ||
|
|
81385cf820 | ||
|
|
cce65e19e1 | ||
|
|
c4b02645f5 | ||
|
|
49e70746f0 | ||
|
|
00ace3bb63 | ||
|
|
efde37eb36 | ||
|
|
84499ab969 | ||
|
|
5d26bb2566 | ||
|
|
657450c40c | ||
|
|
cf2b659491 | ||
|
|
e9679ce993 | ||
|
|
68c5d61d60 | ||
|
|
c4f0236ec0 | ||
|
|
1839c144fa | ||
|
|
d077936a21 | ||
|
|
6c1638890c | ||
|
|
7f0f789953 | ||
|
|
83a2a7a1c2 | ||
|
|
70fb4d452e | ||
|
|
5ed1d4e178 | ||
|
|
35834d3dba | ||
|
|
260d9b9770 | ||
|
|
3907e9eedd | ||
|
|
cf8b00890f | ||
|
|
f1fd25e95e | ||
|
|
426503e062 | ||
|
|
b1834b7cf8 | ||
|
|
27f9cd591d | ||
|
|
5bbc7c8ba2 | ||
|
|
08f8f58971 | ||
|
|
7871e705bf | ||
|
|
1820308ba2 | ||
|
|
a07229846f | ||
|
|
a7e4656834 | ||
|
|
872d54a2dd | ||
|
|
496136b52c | ||
|
|
c4eff00ed7 | ||
|
|
27d8aa0f04 | ||
|
|
bb057b1dad | ||
|
|
3b9d84e2b1 | ||
|
|
1a17de9d39 | ||
|
|
f6ade5dc84 | ||
|
|
2116f19106 | ||
|
|
b73a7e07d2 | ||
|
|
14d3a624d8 | ||
|
|
dd88345483 | ||
|
|
e58d5a54b1 | ||
|
|
2a95a5bf8a | ||
|
|
0c4e67a951 | ||
|
|
78d41b8e41 | ||
|
|
d5347176e1 | ||
|
|
6d91dad8e4 | ||
|
|
1dd5c97ae0 | ||
|
|
e80e5b0801 | ||
|
|
052d8ba879 | ||
|
|
d08ca9585a | ||
|
|
50c33dfcdf | ||
|
|
42c3c2b804 | ||
|
|
f83eeac5e2 | ||
|
|
d5517ede45 | ||
|
|
2339f1a01d | ||
|
|
6129924eb2 | ||
|
|
fce9ded30a | ||
|
|
8489907cf5 | ||
|
|
84ccde268e | ||
|
|
c191df5434 | ||
|
|
f49934a75b | ||
|
|
be3326d0d9 | ||
|
|
7919019b67 | ||
|
|
89d856a487 | ||
|
|
978a24ffab | ||
|
|
bd41cf377a | ||
|
|
3ee3f7e30b | ||
|
|
a032614dc7 | ||
|
|
0377d13d3d | ||
|
|
06fdfc2e14 | ||
|
|
510552c5e6 | ||
|
|
6675c273fd | ||
|
|
9131a69983 | ||
|
|
a8baf0ef45 | ||
|
|
5e4f32d808 | ||
|
|
5a8d18edf3 | ||
|
|
f34b238713 | ||
|
|
ad5c7d97ca | ||
|
|
c35f9c1315 | ||
|
|
e84ed61339 | ||
|
|
8265829105 | ||
|
|
a76d00a08e | ||
|
|
131864b940 | ||
|
|
d33a3f619a | ||
|
|
1a0e57d926 | ||
|
|
5e5845547e | ||
|
|
0de944be28 | ||
|
|
5df438fd2a | ||
|
|
6329f60dff | ||
|
|
0bf9a87293 | ||
|
|
6ae4c49c1a | ||
|
|
c683ae69af | ||
|
|
ab9b12e883 | ||
|
|
c41b506741 | ||
|
|
848180dc08 | ||
|
|
a7d39913fd | ||
|
|
85ca2152e4 | ||
|
|
b11b33b63c | ||
|
|
239f58b584 | ||
|
|
474cb48a14 | ||
|
|
577b0dfe1d | ||
|
|
2918e00d33 | ||
|
|
ffc930b871 | ||
|
|
55bffeba4a | ||
|
|
2adb14c320 | ||
|
|
0d4bf1c15a | ||
|
|
a3bf2bdd8c | ||
|
|
bc3a14cde2 | ||
|
|
b3d4e5cfdf | ||
|
|
885355ce53 | ||
|
|
a4d5b68134 | ||
|
|
67f2bc1385 | ||
|
|
d8fb2f9175 | ||
|
|
fcc8d59588 | ||
|
|
1f19ca1665 | ||
|
|
d04f7fc6e9 | ||
|
|
f9370718bc | ||
|
|
8d888b426f | ||
|
|
b6bd39660f | ||
|
|
959ba94eca | ||
|
|
d5cd1058ab | ||
|
|
80c7b04831 | ||
|
|
7017756140 | ||
|
|
d9a132b649 | ||
|
|
60a68aa136 | ||
|
|
464e4c1938 | ||
|
|
796f630a7c | ||
|
|
dc8f9e043d | ||
|
|
6afcf43ff2 | ||
|
|
e0ea7be499 | ||
|
|
4bf968a45a | ||
|
|
a86963d62d | ||
|
|
e40f9c9730 | ||
|
|
96be7c8990 | ||
|
|
3ced3f4c82 | ||
|
|
72eb240c3b | ||
|
|
20d247b3f7 | ||
|
|
318457cb2c | ||
|
|
336c9d6caa | ||
|
|
a7737912b0 | ||
|
|
f244aba03d | ||
|
|
b0c196cf82 | ||
|
|
cf5769753a | ||
|
|
d1217e84c7 | ||
|
|
172ce6c79f | ||
|
|
2746efeb25 | ||
|
|
b2e7fb01a9 | ||
|
|
9ef1545d06 | ||
|
|
fc1d58b631 | ||
|
|
2ebad55a59 | ||
|
|
d66a05dc41 | ||
|
|
998a5b080d | ||
|
|
39a0f54b0d | ||
|
|
024a823c78 | ||
|
|
1bbb424322 | ||
|
|
b04f04776b | ||
|
|
f0860ec145 | ||
|
|
658e0c6b03 | ||
|
|
49fa093767 | ||
|
|
51aed3ca0a | ||
|
|
b9cc914729 | ||
|
|
d084a37e11 | ||
|
|
cfd2c41c21 | ||
|
|
9dee4c158d | ||
|
|
6b8011228e | ||
|
|
2cd27d0d4a | ||
|
|
3dff09424d | ||
|
|
8e15a6e798 | ||
|
|
2756e12762 | ||
|
|
4eb71bcd14 | ||
|
|
2177df51a8 | ||
|
|
40c8e4832a | ||
|
|
3377bd4ae5 | ||
|
|
38c4f4f76c | ||
|
|
280c7c851f | ||
|
|
af9ccf0c09 | ||
|
|
e7cdac90f5 | ||
|
|
7aefcab8b0 | ||
|
|
514b90ac69 | ||
|
|
dbcb97949f | ||
|
|
76d559efc1 | ||
|
|
8d8584849c | ||
|
|
59a2cbefcb | ||
|
|
c568284f1b | ||
|
|
a8b26570e0 | ||
|
|
5a74b40ae4 | ||
|
|
04f595cd97 | ||
|
|
99a3102134 | ||
|
|
3a42979e53 | ||
|
|
912a53318e | ||
|
|
421401ae3f | ||
|
|
e15475449c | ||
|
|
31750b5ee5 | ||
|
|
bc92f6d4a4 | ||
|
|
1969e78d54 | ||
|
|
317f666d4c | ||
|
|
3fe68a051a | ||
|
|
5bfecc6152 | ||
|
|
e44ed2681f | ||
|
|
27a545f79d | ||
|
|
6b10f4241d | ||
|
|
73cc34467a | ||
|
|
ec1ff52dfb | ||
|
|
2761c40781 | ||
|
|
e981d90209 | ||
|
|
f965e1c3ff | ||
|
|
5b5a79b90b | ||
|
|
15729e9ea0 | ||
|
|
d9eb320bba | ||
|
|
cba016df74 | ||
|
|
cf36f5a23b | ||
|
|
34d2527606 | ||
|
|
8e8e695db9 | ||
|
|
9928f1b3c1 | ||
|
|
36c91c3984 | ||
|
|
b7b1714f32 | ||
|
|
2d1f1640f3 | ||
|
|
371a30f08b | ||
|
|
40dd23337c | ||
|
|
3114dfd39b | ||
|
|
04b34adec6 | ||
|
|
594e837440 | ||
|
|
c77fa12bda | ||
|
|
5674c9f4c2 | ||
|
|
bc01488a75 | ||
|
|
c3c6880382 | ||
|
|
1f2f5858c0 | ||
|
|
22259a322d | ||
|
|
06f59f4e8a | ||
|
|
2b7adeb220 | ||
|
|
05bd452f76 | ||
|
|
a6426d0ac5 | ||
|
|
5dd5c9c605 | ||
|
|
0e4b28ac25 | ||
|
|
62fecdcaa8 | ||
|
|
440558c44f | ||
|
|
fa9a92f214 | ||
|
|
c5af11f6bd | ||
|
|
ad3254deb6 | ||
|
|
fce04b9424 | ||
|
|
2d512c714b | ||
|
|
6298c586fd | ||
|
|
abca8535cf | ||
|
|
677374de86 | ||
|
|
92d015333a | ||
|
|
6c91304400 | ||
|
|
04b5002d8f | ||
|
|
9bde7a6daa | ||
|
|
33b54f3d0c | ||
|
|
c5b073702c | ||
|
|
e38bdd0d2d | ||
|
|
9c54e48194 | ||
|
|
12e048a7fb | ||
|
|
11400e43dc | ||
|
|
293b4960f3 | ||
|
|
22996854f7 | ||
|
|
71e58c768c | ||
|
|
bb3606b64f | ||
|
|
7a82777fc5 | ||
|
|
ec046411f1 | ||
|
|
ffaf968940 | ||
|
|
feb70aeb6b | ||
|
|
ded106b9e3 | ||
|
|
cfdcabc8b4 | ||
|
|
ab448988ff | ||
|
|
e3089d60ea | ||
|
|
34f892ae82 | ||
|
|
fbbf0ed41c | ||
|
|
66a8780fa2 | ||
|
|
2c610258d1 | ||
|
|
f7430d74a7 | ||
|
|
421d6db592 | ||
|
|
1d385fd35a | ||
|
|
7cb31581d5 | ||
|
|
768d550ee2 | ||
|
|
4fd7480557 | ||
|
|
7c0f0a59eb | ||
|
|
93aeee1611 | ||
|
|
86d9e1e816 | ||
|
|
73211c900b | ||
|
|
a19d4c19d3 | ||
|
|
cf3b7f2c16 | ||
|
|
2f21dd81b0 | ||
|
|
db3b3ed9eb | ||
|
|
9625d94aa0 | ||
|
|
5dec7d534f | ||
|
|
0317eec10d | ||
|
|
a34ab1d36e | ||
|
|
7144a0fb9b | ||
|
|
421924b73f | ||
|
|
466236e32f | ||
|
|
636f2d659f | ||
|
|
838a9c000c | ||
|
|
7a7c59e91a | ||
|
|
1ac6ab4428 | ||
|
|
dc3c82ad40 | ||
|
|
0f0a2dddfe | ||
|
|
c3f955d3f1 | ||
|
|
7b1832bd24 | ||
|
|
148c9533ae | ||
|
|
df96318662 | ||
|
|
d9d0be0256 | ||
|
|
de70d82cea | ||
|
|
81db44f584 | ||
|
|
d733d246f0 | ||
|
|
1c5170b759 | ||
|
|
367526f750 | ||
|
|
7a0830de15 | ||
|
|
a5fbfa3748 | ||
|
|
912a7a1781 | ||
|
|
563701fed8 | ||
|
|
414889e03b | ||
|
|
8d2de036d5 | ||
|
|
764761cfa5 | ||
|
|
90a0bb5acb | ||
|
|
0e4379f075 | ||
|
|
968c5dc4aa | ||
|
|
fedb15d5d0 | ||
|
|
ccc6bf05e8 | ||
|
|
a40e56bcb7 | ||
|
|
52453eaeff | ||
|
|
ff3337feed | ||
|
|
cd30a99fae | ||
|
|
081460e59d | ||
|
|
17a6d716ad | ||
|
|
d833de793d | ||
|
|
a6ff62c79c | ||
|
|
92457f7fab | ||
|
|
17fa2f4053 | ||
|
|
bce84376d3 | ||
|
|
be87cdddeb | ||
|
|
dc22661744 | ||
|
|
dc69d20ec9 | ||
|
|
22ed7ea3f2 | ||
|
|
2112fa919a | ||
|
|
f65702a8a8 | ||
|
|
68d19d4717 | ||
|
|
a6e0ec38e7 | ||
|
|
6415ae79be | ||
|
|
79b76fb5f4 | ||
|
|
42012389c4 | ||
|
|
4b5c43f080 | ||
|
|
d16e5090a6 | ||
|
|
ddbe680a58 | ||
|
|
2f50b57e76 | ||
|
|
dc291fa811 | ||
|
|
a1d499ed64 | ||
|
|
629f2e0043 | ||
|
|
5d321c4dac | ||
|
|
9d751e0c72 | ||
|
|
6f8fb561c6 | ||
|
|
1019872832 | ||
|
|
091471293d | ||
|
|
d7281286ba | ||
|
|
5cfda2803d | ||
|
|
9ee7a14685 | ||
|
|
40a6574b95 | ||
|
|
891e1388ba | ||
|
|
0fba7d41a6 | ||
|
|
1595fb8739 | ||
|
|
ebc852b358 | ||
|
|
5f5846a08b | ||
|
|
4d3d9cca2a | ||
|
|
7b77e9f9ae | ||
|
|
0f74e372ba | ||
|
|
a3b99dc309 | ||
|
|
d73d571f19 | ||
|
|
8a8ac1ffe6 | ||
|
|
d463c82c95 | ||
|
|
558af7a454 | ||
|
|
d57ebb3c94 | ||
|
|
855976df84 | ||
|
|
6c2a8d6047 | ||
|
|
38a856f7ff | ||
|
|
fb2a7d8cd1 | ||
|
|
b3f79e5b02 | ||
|
|
1722148333 | ||
|
|
27e96999cf | ||
|
|
7efa152418 | ||
|
|
2a45455c80 | ||
|
|
c06f49cb3e | ||
|
|
b837c68df8 | ||
|
|
f3ebb2e9ce | ||
|
|
df9f72134b | ||
|
|
4ff5004d7c | ||
|
|
bdf3d60148 | ||
|
|
e2c6546b61 | ||
|
|
1f0ee9837b | ||
|
|
71072f084e | ||
|
|
98651c2a14 | ||
|
|
74e5e5e182 | ||
|
|
16f9dbfe37 | ||
|
|
12f74de9b3 | ||
|
|
fec49e1e28 | ||
|
|
9dd9bb7092 | ||
|
|
9c07aab2d6 | ||
|
|
41a84cef23 | ||
|
|
8942e3e78d | ||
|
|
040fe58693 | ||
|
|
45398b7660 | ||
|
|
f3950a5a65 | ||
|
|
6f6c5129d1 | ||
|
|
139697b9cd | ||
|
|
ddd459426d | ||
|
|
3387c135ad | ||
|
|
73133b61fb | ||
|
|
ba0f594548 | ||
|
|
f4fa9bf51a | ||
|
|
9aea85a953 | ||
|
|
f878e5e635 | ||
|
|
4e2fb38d62 | ||
|
|
ee845376b5 | ||
|
|
75234da135 | ||
|
|
7dc9434aec | ||
|
|
5986cf4254 | ||
|
|
f6db636473 | ||
|
|
b30db08110 | ||
|
|
2dbef6105d | ||
|
|
eeee9625c1 | ||
|
|
76559b352b | ||
|
|
a3bf0d6002 | ||
|
|
96ae0dd23a | ||
|
|
9c9e04c5a0 | ||
|
|
15381c7832 | ||
|
|
175f929023 | ||
|
|
a23846b3a1 | ||
|
|
42c74e864a | ||
|
|
809f5d6d8e | ||
|
|
ff41a61432 | ||
|
|
28b531593a | ||
|
|
4d2f4f1be3 | ||
|
|
f97415755b | ||
|
|
67fa82cf14 | ||
|
|
1d38f5a4d5 | ||
|
|
e70f8471a8 | ||
|
|
093e737af9 | ||
|
|
1ae0b44bc5 | ||
|
|
17aeec59a3 | ||
|
|
b20507ef0a | ||
|
|
753995a91d | ||
|
|
67c67dd86d | ||
|
|
fdc0b283d7 | ||
|
|
2abc51789e | ||
|
|
1190b9c278 | ||
|
|
3a8e049093 | ||
|
|
f32a647a20 | ||
|
|
4645f512d1 | ||
|
|
3d89999a06 | ||
|
|
cb5c932447 | ||
|
|
ddf8aef4f7 | ||
|
|
2714ed503b | ||
|
|
78d96355dd | ||
|
|
bf429b7e87 | ||
|
|
2f44046622 | ||
|
|
fb106967bc | ||
|
|
32720bd372 | ||
|
|
fb1de5c1c6 | ||
|
|
69cb71ad7e | ||
|
|
0a9b98ed67 | ||
|
|
e1c4a5989b | ||
|
|
cac988f8e2 | ||
|
|
bbe92a3a40 | ||
|
|
a489550752 | ||
|
|
f1dbff1dd4 | ||
|
|
55ea0f398b | ||
|
|
38abb044d0 | ||
|
|
ca4e76b34f | ||
|
|
55e0086958 | ||
|
|
050ebb3b19 | ||
|
|
31f788eb5e | ||
|
|
f23b16db2b | ||
|
|
060f80c239 | ||
|
|
6c3d3b98b8 | ||
|
|
21dfbd0103 | ||
|
|
1a10569f6d | ||
|
|
33396ca9c1 | ||
|
|
36ba1ff790 | ||
|
|
fdfcff2bb5 | ||
|
|
c74c1a0c5f | ||
|
|
faca83e1e8 | ||
|
|
759ab54e59 | ||
|
|
3c61524f26 | ||
|
|
40013c2b61 | ||
|
|
5d5e7393f8 | ||
|
|
71c5511e6c | ||
|
|
ea83982062 | ||
|
|
cdbbdcba5f | ||
|
|
aeb708fe07 | ||
|
|
78c67ed53d | ||
|
|
ea37ee6cb3 | ||
|
|
2e67c5a045 | ||
|
|
752bc5a454 | ||
|
|
3a4bf8f213 | ||
|
|
bc20664c18 | ||
|
|
e27690e894 | ||
|
|
bac5ac18f7 | ||
|
|
e906b87450 | ||
|
|
9d0415f9e9 | ||
|
|
1d807911e4 | ||
|
|
f51f8ffe45 | ||
|
|
ea9930816f | ||
|
|
699cb92e86 | ||
|
|
f4f4f2d314 | ||
|
|
374472deda | ||
|
|
b27f0dd490 | ||
|
|
141d2b5626 | ||
|
|
cf0f44823a | ||
|
|
6355113af9 | ||
|
|
00ef7ec522 | ||
|
|
9497a4cb5a | ||
|
|
0f71667625 | ||
|
|
8b20e0166d | ||
|
|
567644dabd | ||
|
|
9ef8cdadf6 | ||
|
|
c911568306 | ||
|
|
ce02f798e4 | ||
|
|
21bb2fb03f | ||
|
|
11311d07e5 | ||
|
|
4426bf2615 | ||
|
|
515e973964 | ||
|
|
0a6b934ac1 | ||
|
|
b2e3013898 | ||
|
|
757cedc233 | ||
|
|
ab316b348a | ||
|
|
7b7c4bd116 | ||
|
|
82e751a153 | ||
|
|
c5c50a2141 | ||
|
|
9c32e630a0 | ||
|
|
02e26996c1 | ||
|
|
b25b72ae19 | ||
|
|
ff36375581 | ||
|
|
c9f5edbc1d | ||
|
|
ec00e0a952 | ||
|
|
51a4b86495 | ||
|
|
c3866b7d6b | ||
|
|
6dafca79be | ||
|
|
7aca8d2d1c | ||
|
|
7daef74fc6 | ||
|
|
58d0f3053d | ||
|
|
649f644c75 | ||
|
|
ad2a26611a | ||
|
|
89bb7d0211 | ||
|
|
b3564bf2b4 | ||
|
|
16f452cf2e | ||
|
|
56cedad707 | ||
|
|
4b6325908b | ||
|
|
460d8fc094 | ||
|
|
c435236ceb | ||
|
|
39254229a0 | ||
|
|
6182b205c8 | ||
|
|
e528b439bc | ||
|
|
629140d66c | ||
|
|
46ed4f2de1 | ||
|
|
46d55a8ada | ||
|
|
5e6af3d732 | ||
|
|
0d07c58989 | ||
|
|
6f80be0653 | ||
|
|
1916e688a6 | ||
|
|
07e56ddeb5 | ||
|
|
88c8009116 | ||
|
|
42d843297d | ||
|
|
15cdeeddaf | ||
|
|
3c13a265bc | ||
|
|
93eec9ac3c | ||
|
|
df7dbff683 | ||
|
|
2e6265963b | ||
|
|
b88b18df93 | ||
|
|
fbf5333b39 | ||
|
|
c6e3b490f5 | ||
|
|
19677f0622 | ||
|
|
a8932c2c25 | ||
|
|
f93e33d9de | ||
|
|
649e6efc4a | ||
|
|
a7d3619ec4 | ||
|
|
daca3a5fc9 | ||
|
|
135a52020c | ||
|
|
bf21ed7282 | ||
|
|
b5f65e3304 | ||
|
|
45400a1758 | ||
|
|
acc88bc2b4 | ||
|
|
98b0595275 | ||
|
|
4efecfdfa0 | ||
|
|
b5afb9d3ab | ||
|
|
6a1d58d4e7 | ||
|
|
d2a3db4c78 | ||
|
|
f207788c0a | ||
|
|
84b44069c8 | ||
|
|
09ed3f37db | ||
|
|
f444604e7c | ||
|
|
e1c9885566 | ||
|
|
4e7d905783 | ||
|
|
cb35e3a766 | ||
|
|
67fe5ed699 | ||
|
|
e863fd78d6 | ||
|
|
4ea2518e79 | ||
|
|
b508ab240f | ||
|
|
c545ec727c | ||
|
|
f290b9a145 | ||
|
|
fa1eb9bf25 | ||
|
|
3a32b83181 | ||
|
|
2e393b7d5c | ||
|
|
12e5b8124e | ||
|
|
26e939c1eb | ||
|
|
f09390a412 | ||
|
|
3067807802 | ||
|
|
60f4c9f5b3 | ||
|
|
b0ecafcb8d | ||
|
|
ddfb76e9e0 | ||
|
|
6f27f742fe | ||
|
|
c1a64301ce | ||
|
|
1ee690e87c | ||
|
|
28e0dbc02f | ||
|
|
a2604a36bc | ||
|
|
d031c5c7fa | ||
|
|
5d01b32c10 | ||
|
|
4fe651079c | ||
|
|
a573ea4aeb | ||
|
|
13704d9da5 | ||
|
|
73a1e137e6 | ||
|
|
0ec9c6c3cf | ||
|
|
4aa275e13c | ||
|
|
b557a73c3f | ||
|
|
b66098ea20 | ||
|
|
d0cefecd0d | ||
|
|
38a4e9806f | ||
|
|
3c64a57c84 | ||
|
|
36b0796976 | ||
|
|
3241d81ce5 | ||
|
|
d7a188fb34 | ||
|
|
5b217b2042 | ||
|
|
4cb2a92037 | ||
|
|
24d90c17c2 | ||
|
|
c95c6d72e9 | ||
|
|
99b174f495 | ||
|
|
5949ef0e2c | ||
|
|
d75d64df64 | ||
|
|
a5164df293 | ||
|
|
690113dd73 | ||
|
|
fe87160b19 | ||
|
|
fffe1be521 | ||
|
|
dc02bcee74 | ||
|
|
e7a9313135 | ||
|
|
5492845659 | ||
|
|
29dfe89137 | ||
|
|
0da3f84a2e | ||
|
|
c25b0c1a66 | ||
|
|
7c7314f673 | ||
|
|
869cc3d497 | ||
|
|
f315bf074b | ||
|
|
d33f9ddf44 | ||
|
|
fcf0c28132 | ||
|
|
b3e50cbb33 | ||
|
|
20cb709ae3 | ||
|
|
916a41ed60 | ||
|
|
9797a9993a | ||
|
|
04ce98148d | ||
|
|
34eb75f634 | ||
|
|
05b76281f7 |
48
.dockerignore
Normal file
48
.dockerignore
Normal file
@@ -0,0 +1,48 @@
|
||||
.git
|
||||
.worktrees
|
||||
.bun-cache
|
||||
.bun
|
||||
.tmp
|
||||
**/.tmp
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.webp
|
||||
*.gif
|
||||
*.mp4
|
||||
*.mov
|
||||
*.wav
|
||||
*.mp3
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
.turbo
|
||||
**/.turbo
|
||||
.cache
|
||||
**/.cache
|
||||
.next
|
||||
**/.next
|
||||
coverage
|
||||
**/coverage
|
||||
*.log
|
||||
tmp
|
||||
**/tmp
|
||||
|
||||
# build artifacts
|
||||
dist
|
||||
**/dist
|
||||
apps/macos/.build
|
||||
apps/ios/build
|
||||
**/*.trace
|
||||
|
||||
# large app trees not needed for CLI build
|
||||
apps/
|
||||
assets/
|
||||
Peekaboo/
|
||||
Swabble/
|
||||
Core/
|
||||
Users/
|
||||
vendor/
|
||||
364
.github/workflows/ci.yml
vendored
364
.github/workflows/ci.yml
vendored
@@ -5,22 +5,79 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: build
|
||||
command: pnpm build
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: lint
|
||||
command: bunx biome check src
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: bunx vitest run
|
||||
- runtime: bun
|
||||
task: build
|
||||
command: bunx tsc -p tsconfig.json
|
||||
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 Node.js
|
||||
if: matrix.runtime == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Node version
|
||||
- name: Setup Bun
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
# 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)
|
||||
if: matrix.runtime == 'bun'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
@@ -41,11 +98,300 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
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
|
||||
- task: test
|
||||
command: |
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- 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
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-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: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint / SwiftFormat
|
||||
run: brew install swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- 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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
||||
- task: build
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
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: Run Android ${{ matrix.task }}
|
||||
working-directory: apps/android
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -1,6 +1,50 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
coverage
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/ClawdbotKit/.build/
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
bin/docs-list
|
||||
apps/macos/.build-local/
|
||||
apps/macos/.swiftpm/
|
||||
apps/shared/ClawdbotKit/.swiftpm/
|
||||
Core/
|
||||
apps/ios/*.xcodeproj/
|
||||
apps/ios/*.xcworkspace/
|
||||
apps/ios/.swiftpm/
|
||||
vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
.bundle.hash
|
||||
|
||||
# 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
|
||||
.env
|
||||
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
|
||||
51
.swiftformat
Normal file
51
.swiftformat
Normal file
@@ -0,0 +1,51 @@
|
||||
# SwiftFormat configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
||||
|
||||
--swiftversion 6.2
|
||||
|
||||
# Self handling
|
||||
--self insert
|
||||
--selfrequired
|
||||
|
||||
# Imports / extensions
|
||||
--importgrouping testable-bottom
|
||||
--extensionacl on-declarations
|
||||
|
||||
# Indentation
|
||||
--indent 4
|
||||
--indentcase false
|
||||
--ifdef no-indent
|
||||
--xcodeindentation enabled
|
||||
|
||||
# Line breaks
|
||||
--linebreaks lf
|
||||
--maxwidth 120
|
||||
|
||||
# Whitespace
|
||||
--trimwhitespace always
|
||||
--emptybraces no-space
|
||||
--nospaceoperators ...,..<
|
||||
--ranges no-space
|
||||
--someAny true
|
||||
--voidtype void
|
||||
|
||||
# Wrapping
|
||||
--wraparguments before-first
|
||||
--wrapparameters before-first
|
||||
--wrapcollections before-first
|
||||
--closingparen same-line
|
||||
|
||||
# Organization
|
||||
--organizetypes class,struct,enum,extension
|
||||
--extensionmark "MARK: - %t + %p"
|
||||
--marktypes always
|
||||
--markextensions always
|
||||
--structthreshold 0
|
||||
--enumthreshold 0
|
||||
|
||||
# Other
|
||||
--stripunusedargs closure-only
|
||||
--header ignore
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
146
.swiftlint.yml
Normal file
146
.swiftlint.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
# SwiftLint configuration adapted from Peekaboo defaults (Swift 6 friendly)
|
||||
|
||||
included:
|
||||
- apps/macos/Sources
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.build"
|
||||
- "**/.swiftpm"
|
||||
- "**/DerivedData"
|
||||
- "**/Generated"
|
||||
- "**/Resources"
|
||||
- "**/Package.swift"
|
||||
- "**/Tests/Resources"
|
||||
- node_modules
|
||||
- dist
|
||||
- coverage
|
||||
- "*.playground"
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
- unused_import
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- contains_over_first_not_nil
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- fallthrough
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- pattern_matching_keywords
|
||||
- private_outlet
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
|
||||
disabled_rules:
|
||||
# SwiftFormat handles these
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- trailing_comma
|
||||
- vertical_whitespace
|
||||
- indentation_width
|
||||
|
||||
# Style exclusions
|
||||
- explicit_self
|
||||
- identifier_name
|
||||
- file_header
|
||||
- explicit_top_level_acl
|
||||
- explicit_acl
|
||||
- explicit_type_interface
|
||||
- missing_docs
|
||||
- required_deinit
|
||||
- prefer_nimble
|
||||
- quick_discouraged_call
|
||||
- quick_discouraged_focused_test
|
||||
- quick_discouraged_pending_test
|
||||
- anonymous_argument_in_multiline_closure
|
||||
- no_extension_access_modifier
|
||||
- no_grouping_extension
|
||||
- switch_case_on_newline
|
||||
- strict_fileprivate
|
||||
- extension_access_modifier
|
||||
- convenience_type
|
||||
- no_magic_numbers
|
||||
- one_declaration_per_file
|
||||
- vertical_whitespace_between_cases
|
||||
- vertical_whitespace_closing_braces
|
||||
- superfluous_else
|
||||
- number_separator
|
||||
- prefixed_toplevel_constant
|
||||
- opening_brace
|
||||
- trailing_closure
|
||||
- contrasted_opening_brace
|
||||
- sorted_imports
|
||||
- redundant_type_annotation
|
||||
- shorthand_optional_binding
|
||||
- untyped_error_in_catch
|
||||
- file_name
|
||||
- todo
|
||||
|
||||
force_cast: warning
|
||||
force_try: warning
|
||||
|
||||
type_name:
|
||||
min_length:
|
||||
warning: 2
|
||||
error: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
error: 80
|
||||
|
||||
function_body_length:
|
||||
warning: 150
|
||||
error: 300
|
||||
|
||||
function_parameter_count:
|
||||
warning: 7
|
||||
error: 10
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
type_body_length:
|
||||
warning: 800
|
||||
error: 1200
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 20
|
||||
error: 120
|
||||
|
||||
large_tuple:
|
||||
warning: 4
|
||||
error: 5
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 4
|
||||
error: 6
|
||||
function_level:
|
||||
warning: 5
|
||||
error: 7
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 250
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
reporter: "xcode"
|
||||
51
AGENTS.md
51
AGENTS.md
@@ -1,13 +1,13 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, Twilio in `src/twilio`, Web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts` plus e2e in `src/cli/relay.e2e.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Claude config). Built output lives in `dist/`.
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Install deps: `pnpm install`
|
||||
- Run CLI in dev: `pnpm warelay ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -16,40 +16,63 @@
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Environment: copy `.env.example`; set Twilio creds and WhatsApp sender (`TWILIO_WHATSAPP_FROM`).
|
||||
- Web provider stores creds at `~/.warelay/credentials/`; rerun `warelay login` if logged out.
|
||||
- Media hosting relies on Tailscale Funnel when using Twilio; use `warelay webhook --ingress tailscale` or `--serve-media` for local hosting.
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/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.
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Relay is managed by launchctl (label `com.steipete.warelay`). After code changes restart with `launchctl kickstart -k gui/$UID/com.steipete.warelay` and verify via `launchctl list | grep warelay`. Use tmux only if you spin up a temporary relay yourself and clean it up afterward.
|
||||
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **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.clawdbot`; 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.
|
||||
- When asked to open a “session” file, open the Pi/Tau session logs under `~/.pi/agent/sessions/warelay/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`.
|
||||
- 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.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) instead of manual conflict resolution.
|
||||
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||
- **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:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||
- **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.
|
||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- 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:
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\!` in command arguments. When using `warelay send` with messages containing exclamation marks, use heredoc syntax:
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\!" with backslash
|
||||
warelay send --provider web --to "+1234" --message 'Hello!'
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
warelay send --provider web --to "+1234" --message "$(cat <<'EOF'
|
||||
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
This is a Claude Code quirk, not a warelay bug.
|
||||
This is a Claude Code quirk, not a clawdbot bug.
|
||||
|
||||
177
CHANGELOG.md
177
CHANGELOG.md
@@ -1,115 +1,110 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.0 — 2025-12-03
|
||||
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
||||
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
|
||||
|
||||
|
||||
## 2026.1.4
|
||||
|
||||
### 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`.
|
||||
- **Group chats (web provider):** Warelay 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.
|
||||
- **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 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).
|
||||
- **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.
|
||||
- **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/Twilio heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||
- **Control via WhatsApp:** Send `/restart` to restart the warelay launchd service (`com.steipete.warelay`) 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.
|
||||
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
|
||||
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
|
||||
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
|
||||
|
||||
### Reliability & UX
|
||||
- Outbound chunking prefers newlines/word boundaries and enforces caps (1600 WhatsApp/Twilio, 4000 web).
|
||||
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
||||
- IPC relay send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
||||
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
||||
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
||||
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
||||
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool removes node-pty `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||
|
||||
### Security / Hardening
|
||||
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `warelay logout` also prunes session store.
|
||||
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
||||
### Fixes
|
||||
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
|
||||
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
|
||||
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||
- WhatsApp GIF playback for MP4-based GIFs.
|
||||
- Onboarding + Control UI basePath handling fixes and UI polish.
|
||||
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
|
||||
- Canvas host watcher resilience; build and packaging edge cases cleaned up.
|
||||
|
||||
### Bug Fixes
|
||||
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
||||
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
||||
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
||||
- 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.
|
||||
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||
- Tau 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.
|
||||
### Docs
|
||||
- Sandbox setup, hot reload, port config, and session announce step coverage.
|
||||
- Skills and onboarding clarifications + additional examples.
|
||||
|
||||
### Testing
|
||||
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||
## 2026.1.3 (beta 5)
|
||||
|
||||
## 1.3.0 — 2025-12-02
|
||||
### Breaking
|
||||
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
|
||||
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
|
||||
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
|
||||
- Discord/Telegram require `enabled: true` in config when using env tokens.
|
||||
- Routing `allowFrom`/mention settings moved to per-surface group settings.
|
||||
|
||||
### Highlights
|
||||
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
|
||||
- Discord: expanded tool actions, richer routing, and threaded reply tags.
|
||||
- Auto-reply queue modes + session model overrides; TUI upgrades.
|
||||
- Nix mode (declarative config) and Docker setup flow.
|
||||
- Onboarding wizard + configure/doctor/update flows.
|
||||
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
|
||||
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).
|
||||
|
||||
### Bug Fixes
|
||||
- Empty `result` fields no longer leak raw JSON to users.
|
||||
- Heartbeat alerts now honor `responsePrefix`.
|
||||
- Command failures return user-friendly messages.
|
||||
- Test session isolation to avoid touching real `sessions.json`.
|
||||
- IPC reuse for `warelay send/heartbeat` prevents Signal/WhatsApp session corruption.
|
||||
- Web send respects media kind (image/audio/video/document) with correct limits.
|
||||
### Fixes
|
||||
- macOS codesign/TCC hardening and menu/UI stability improvements.
|
||||
- Streaming/typing fixes; per-provider chunk limit tuning.
|
||||
- Remote gateway auth + token handling tightened.
|
||||
- Camera capture reliability and media sizing fixes.
|
||||
|
||||
### Changes
|
||||
- IPC relay socket at `~/.warelay/relay.sock` with automatic CLI fallback.
|
||||
- Batched inbound messages with timestamps; typing indicator after IPC sends.
|
||||
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||
- Early `allowFrom` filtering before decryption.
|
||||
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
|
||||
## 2025.12.27 (betas 3–4)
|
||||
|
||||
## 1.2.2 — 2025-11-28
|
||||
### Highlights
|
||||
- First-class tools replace `clawdbot-*` skills (browser, canvas, nodes, cron).
|
||||
- Per-session model selection and custom model providers.
|
||||
- Group activation commands; Discord provider for DMs/guilds.
|
||||
- Gateway webhooks + Gmail Pub/Sub hooks.
|
||||
- Command queue modes + `agent.maxConcurrent` cap.
|
||||
- Background bash tasks with `process` tool; gateway in-process restart.
|
||||
|
||||
### Changes
|
||||
- Manual heartbeat sends: `warelay heartbeat --message/--body --provider web|twilio`; `--dry-run` previews payloads.
|
||||
### Fixes
|
||||
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
|
||||
- macOS menu/Chat UI polish and presence reporting fixes.
|
||||
|
||||
## 1.2.1 — 2025-11-28
|
||||
## 2025.12.21 (beta 2)
|
||||
|
||||
### Changes
|
||||
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
||||
### Highlights
|
||||
- Bundled gateway packaging + DMG distribution pipeline.
|
||||
- Skills platform (bundled/managed/workspace) with install gating + UI.
|
||||
- Onboarding polish and agent UX improvements.
|
||||
- Canvas host served from Gateway; browser control simplification.
|
||||
|
||||
### Planned / in progress (from prior notes)
|
||||
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
|
||||
- Heartbeat delivery preview (Claude path) dry-run.
|
||||
- Simulated inbound hook for local testing.
|
||||
## 2025.12.19 (beta 1)
|
||||
|
||||
## 1.2.0 — 2025-11-27
|
||||
### Highlights
|
||||
- First Clawdbot release: Gateway WS control plane + optional Bridge.
|
||||
- macOS menu bar companion app with Voice Wake + WebChat.
|
||||
- iOS node pairing with Canvas surface.
|
||||
- WhatsApp groups, thinking/verbose directives, health/status tooling.
|
||||
|
||||
### Changes
|
||||
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
|
||||
- Heartbeat tooling: `--session-id`, `--heartbeat-now`, relay helpers `relay:heartbeat` and `relay:heartbeat:tmux`.
|
||||
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
|
||||
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
|
||||
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; Twilio media hosting via shared host module.
|
||||
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
|
||||
### Breaking
|
||||
- Switched to Pi-only agent runtime; legacy providers removed.
|
||||
- Gateway became the single source of truth (no ad-hoc direct sends).
|
||||
|
||||
## 1.1.0 — 2025-11-26
|
||||
## 2025.12.05–2025.12.03 (pre-Clawdbot)
|
||||
|
||||
### Changes
|
||||
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
|
||||
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
|
||||
- Optional audio transcription via external CLI.
|
||||
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||
- Web provider refactor; logout command; web-only relay start helper.
|
||||
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
||||
- Relay help prints effective heartbeat/backoff when in web mode.
|
||||
### Highlights
|
||||
- Pi-only agent path and web-only gateway workflow.
|
||||
- Thinking/verbose directives, group chat support, and heartbeat controls.
|
||||
- `clawdbot agent` CLI added; session tables and health reporting.
|
||||
|
||||
## 1.0.4 — 2025-11-25
|
||||
## 2025.11.28–2025.11.25 (early web-only)
|
||||
|
||||
### Changes
|
||||
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
|
||||
- Web relay auto-reconnects after Baileys/WebSocket drops; close propagation tests.
|
||||
|
||||
## 0.1.3 — 2025-11-25
|
||||
|
||||
### Changes
|
||||
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
||||
- Added tests for timeout fallback and partial-output truncation.
|
||||
- Heartbeat CLI + interval handling.
|
||||
- Media MIME sniffing, size caps, and timeout fallbacks.
|
||||
- Web provider reconnects and early stability fixes.
|
||||
|
||||
42
CONTRIBUTING.md
Normal file
42
CONTRIBUTING.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Contributing to Clawdbot
|
||||
|
||||
Welcome to the lobster tank! 🦞
|
||||
|
||||
## Quick Links
|
||||
- **GitHub:** https://github.com/clawdbot/clawdbot
|
||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
||||
|
||||
## Maintainers
|
||||
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
## How to Contribute
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
||||
3. **Questions** → Discord #setup-help
|
||||
|
||||
## Before You PR
|
||||
- Test locally with your Clawdbot instance
|
||||
- Run linter: `npm run lint`
|
||||
- Keep PRs focused (one thing per PR)
|
||||
- Describe what & why
|
||||
|
||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
||||
|
||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
|
||||
Please include in your PR:
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
16
Dockerfile.sandbox
Normal file
16
Dockerfile.sandbox
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
jq \
|
||||
python3 \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
27
Dockerfile.sandbox-browser
Normal file
27
Dockerfile.sandbox-browser
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
chromium \
|
||||
curl \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
git \
|
||||
jq \
|
||||
novnc \
|
||||
python3 \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["clawdbot-sandbox-browser"]
|
||||
1
Peekaboo
Submodule
1
Peekaboo
Submodule
Submodule Peekaboo added at c1243a7978
359
README.md
359
README.md
@@ -1,7 +1,7 @@
|
||||
# 🦞 CLAWDIS — WhatsApp Gateway for AI Agents
|
||||
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -9,132 +9,289 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/steipete/warelay/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/warelay/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://www.npmjs.com/package/warelay"><img src="https://img.shields.io/npm/v/warelay.svg?style=for-the-badge" alt="npm version"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
**CLAWDIS** (formerly Warelay) is a WhatsApp-to-AI gateway. Send a message, get an AI response. It's like having a genius lobster in your pocket 24/7.
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
|
||||
Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OAuth setup.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first Gateway** — single control plane for sessions, providers, tools, and events.
|
||||
- **Multi-surface inbox** — WhatsApp, Telegram, Discord, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **Live Canvas** — agent-driven visual workspace with A2UI.
|
||||
- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord actions.
|
||||
- **Companion apps** — macOS menu bar app + iOS/Android nodes.
|
||||
- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host.
|
||||
- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI.
|
||||
- Pi agent runtime in RPC mode with tool streaming and block streaming.
|
||||
- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back.
|
||||
- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle.
|
||||
|
||||
### Surfaces + providers
|
||||
- WhatsApp (Baileys), Telegram (grammY), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat.
|
||||
- Group mention gating, reply tags, per-surface chunking and routing.
|
||||
|
||||
### Apps + nodes
|
||||
- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control.
|
||||
- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing.
|
||||
- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS.
|
||||
- macOS node mode: system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- Canvas: A2UI push/reset, eval, snapshot.
|
||||
- Nodes: camera snap/clip, screen record, location.get, notifications.
|
||||
- Cron + wakeups; webhooks; Gmail Pub/Sub triggers.
|
||||
- Skills platform: bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Ops + packaging
|
||||
- Control UI + WebChat served directly from the Gateway.
|
||||
- Tailscale Serve/Funnel or SSH tunnels with token/password auth.
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
|
||||
## Changes since 2.0.0-beta5 (2026-01-03)
|
||||
|
||||
### Highlights
|
||||
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
|
||||
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
|
||||
- Gateway config hot reload, configurable port, and Control UI base-path support.
|
||||
- Sandbox options: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
|
||||
### Fixes
|
||||
- Presence beacons keep node lists fresh; Instances view stays accurate.
|
||||
- Block streaming + chunking reliability (Telegram/Discord ordering, fewer duplicates).
|
||||
- WhatsApp GIF playback for MP4-based GIFs.
|
||||
- Onboarding/Control UI basePath handling fixes + UI polish.
|
||||
- Cleaner logging + clearer tool summaries.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
- Bash tool removed `stdinMode: "pty"` support (use tmux for real TTYs).
|
||||
- Primary session key is fixed to `main` (or `global` for global scope).
|
||||
|
||||
## Project rename + changelog format
|
||||
|
||||
Clawdis → Clawdbot. The rename touched every surface, path, and bundle ID. To make that transition explicit, releases now use **date-based versions** (`YYYY.M.D`), and the changelog is compressed into milestone summaries instead of long semver trains. Full detail still lives in git history and the docs.
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ WhatsApp │ ───▶ │ CLAWDIS │ ───▶ │ AI Agent │
|
||||
│ (You) │ ◀─── │ 🦞⏱️💙 │ ◀─── │ (Tau/Claude)│
|
||||
└─────────────┘ └──────────┘ └─────────────┘
|
||||
Your surfaces
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat (browser)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Why "CLAWDIS"?
|
||||
## Quick start (from source)
|
||||
|
||||
**CLAWDIS** = CLAW + TARDIS
|
||||
|
||||
Because every space lobster needs a time-and-space machine. The Doctor has a TARDIS. [Clawd](https://clawd.me) has a CLAWDIS. Both are blue. Both are chaotic. Both are loved.
|
||||
|
||||
## Features
|
||||
|
||||
- 📱 **WhatsApp Integration** — Personal WhatsApp Web or Twilio
|
||||
- 🤖 **AI Agent Gateway** — Works with Tau/Pi, Claude CLI, Codex, Gemini
|
||||
- 💬 **Session Management** — Per-sender conversation context
|
||||
- 🔔 **Heartbeats** — Periodic check-ins for proactive AI
|
||||
- 👥 **Group Chat Support** — Mention-based triggering
|
||||
- 📎 **Media Support** — Images, audio, documents, voice notes
|
||||
- 🎤 **Voice Transcription** — Whisper integration
|
||||
- 🔧 **Tool Streaming** — Real-time display (💻📄✍️📝)
|
||||
|
||||
## Quick Start
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g warelay # (still warelay on npm for now)
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
# Link your WhatsApp
|
||||
clawdis login
|
||||
# Recommended: run the onboarding wizard
|
||||
pnpm clawdbot onboard
|
||||
|
||||
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||
pnpm clawdbot login
|
||||
|
||||
# Start the gateway
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
|
||||
# Send a message
|
||||
clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
|
||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Start the relay
|
||||
clawdis relay --verbose
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord)
|
||||
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Architecture
|
||||
|
||||
### TypeScript Gateway (src/gateway/server.ts)
|
||||
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||
- **Control UI + Canvas Host**: HTTP serves Control UI assets (default `/`, optional base path) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||
|
||||
### iOS app (apps/ios)
|
||||
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdbot://` deep‑link interception (`ScreenController`).
|
||||
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdbot://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||
|
||||
## Companion apps
|
||||
|
||||
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||
|
||||
### macOS (Clawdbot.app)
|
||||
|
||||
- Menu bar control for the Gateway and health.
|
||||
- Voice Wake + push-to-talk overlay.
|
||||
- WebChat + debug tools.
|
||||
- Remote gateway control over SSH.
|
||||
|
||||
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||
|
||||
### iOS node (internal)
|
||||
|
||||
- Pairs as a node via the Bridge.
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: `docs/ios/connect.md`.
|
||||
|
||||
### Android node (internal)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: `docs/android/connect.md`.
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.clawdis/clawdis.json`:
|
||||
Minimal `~/.clawdbot/clawdbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
allowFrom: ["+1234567890"],
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: ["tau", "--mode", "json", "{{BodyStripped}}"],
|
||||
session: {
|
||||
scope: "per-sender",
|
||||
idleMinutes: 1440
|
||||
},
|
||||
heartbeatMinutes: 10
|
||||
}
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234567890"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
### WhatsApp
|
||||
|
||||
- [Configuration Guide](./docs/configuration.md)
|
||||
- [Agent Integration](./docs/agents.md)
|
||||
- [Group Chats](./docs/group-messages.md)
|
||||
- [Security](./docs/security.md)
|
||||
- [Troubleshooting](./docs/troubleshooting.md)
|
||||
- [The Lore](./docs/lore.md) 🦞
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
|
||||
### Telegram
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discord
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
token: "1234abcd"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
color: "#FF4500"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [`docs/index.md`](docs/index.md) (overview)
|
||||
- [`docs/configuration.md`](docs/configuration.md)
|
||||
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||
- [`docs/gateway.md`](docs/gateway.md)
|
||||
- [`docs/web.md`](docs/web.md)
|
||||
- [`docs/discovery.md`](docs/discovery.md)
|
||||
- [`docs/agent.md`](docs/agent.md)
|
||||
- [`docs/discord.md`](docs/discord.md)
|
||||
- [`docs/wizard.md`](docs/wizard.md)
|
||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
```bash
|
||||
clawdbot hooks gmail setup --account you@gmail.com
|
||||
clawdbot hooks gmail run
|
||||
```
|
||||
- [`docs/security.md`](docs/security.md)
|
||||
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||
- [`docs/clawdbot-mac.md`](docs/clawdbot-mac.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
## Clawd
|
||||
|
||||
CLAWDIS was built for **Clawd**, a space lobster AI assistant. See the full setup in [`docs/clawd.md`](./docs/clawd.md).
|
||||
Clawdbot was built for **Clawd**, a space lobster AI assistant.
|
||||
|
||||
- 🦞 **Clawd's Home:** [clawd.me](https://clawd.me)
|
||||
- 📜 **Clawd's Soul:** [soul.md](https://soul.md)
|
||||
- 👨💻 **Peter's Blog:** [steipete.me](https://steipete.me)
|
||||
- 🐦 **Twitter:** [@steipete](https://twitter.com/steipete)
|
||||
|
||||
## Providers
|
||||
|
||||
### WhatsApp Web (Recommended)
|
||||
```bash
|
||||
clawdis login # Scan QR code
|
||||
clawdis relay # Start listening
|
||||
```
|
||||
|
||||
### Twilio
|
||||
```bash
|
||||
# Set environment variables
|
||||
export TWILIO_ACCOUNT_SID=...
|
||||
export TWILIO_AUTH_TOKEN=...
|
||||
export TWILIO_WHATSAPP_FROM=whatsapp:+1234567890
|
||||
|
||||
clawdis relay --provider twilio
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `clawdis login` | Link WhatsApp Web via QR |
|
||||
| `clawdis send` | Send a message |
|
||||
| `clawdis relay` | Start auto-reply loop |
|
||||
| `clawdis status` | Show recent messages |
|
||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
||||
|
||||
## Credits
|
||||
|
||||
- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator
|
||||
- **Mario Zechner** ([@badlogicgames](https://twitter.com/badlogicgames)) — Tau/Pi, security testing
|
||||
- **Clawd** 🦞 — The space lobster who demanded a better name
|
||||
|
||||
## License
|
||||
|
||||
MIT — Free as a lobster in the ocean.
|
||||
|
||||
---
|
||||
|
||||
*"We're all just playing with our own prompts."*
|
||||
|
||||
🦞💙
|
||||
- https://clawd.me
|
||||
- https://soul.md
|
||||
- https://steipete.me
|
||||
|
||||
54
Swabble/.github/workflows/ci.yml
vendored
Normal file
54
Swabble/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: swabble
|
||||
steps:
|
||||
- name: Checkout swabble
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: swabble
|
||||
|
||||
- name: Select Xcode 26.1 (prefer 26.1.1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# pick the newest installed 26.1.x, fallback to newest 26.x
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26.1*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
CANDIDATE="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true)"
|
||||
fi
|
||||
if [[ -z "$CANDIDATE" ]]; then
|
||||
echo "No Xcode 26.x found on runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Selecting $CANDIDATE"
|
||||
sudo xcode-select -s "$CANDIDATE"
|
||||
xcodebuild -version
|
||||
|
||||
- name: Show Swift version
|
||||
run: swift --version
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
brew update
|
||||
brew install swiftlint swiftformat
|
||||
|
||||
- name: Format check
|
||||
run: |
|
||||
./scripts/format.sh
|
||||
git diff --exit-code
|
||||
|
||||
- name: Lint
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
- name: Test
|
||||
run: swift test --parallel
|
||||
33
Swabble/.gitignore
vendored
Normal file
33
Swabble/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# SwiftPM / Build
|
||||
/.build
|
||||
/.swiftpm
|
||||
/DerivedData
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# Editors
|
||||
/.vscode
|
||||
.idea/
|
||||
|
||||
# Xcode artifacts
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
*.xcplayground
|
||||
playground.xcworkspace
|
||||
timeline.xctimeline
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
8
Swabble/.swiftformat
Normal file
8
Swabble/.swiftformat
Normal file
@@ -0,0 +1,8 @@
|
||||
--swiftversion 6.2
|
||||
--indent 4
|
||||
--maxwidth 120
|
||||
--wraparguments before-first
|
||||
--wrapcollections before-first
|
||||
--stripunusedargs closure-only
|
||||
--self remove
|
||||
--header ""
|
||||
43
Swabble/.swiftlint.yml
Normal file
43
Swabble/.swiftlint.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
# SwiftLint for swabble
|
||||
included:
|
||||
- Sources
|
||||
excluded:
|
||||
- .build
|
||||
- DerivedData
|
||||
- "**/.swiftpm"
|
||||
- "**/.build"
|
||||
- "**/DerivedData"
|
||||
- "**/.DS_Store"
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- explicit_init
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- vertical_parameter_alignment_on_call
|
||||
- vertical_whitespace_opening_braces
|
||||
- vertical_whitespace_closing_braces
|
||||
|
||||
disabled_rules:
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- indentation_width
|
||||
- identifier_name
|
||||
- explicit_self
|
||||
- file_header
|
||||
- todo
|
||||
|
||||
line_length:
|
||||
warning: 140
|
||||
error: 180
|
||||
|
||||
reporter: "xcode"
|
||||
11
Swabble/CHANGELOG.md
Normal file
11
Swabble/CHANGELOG.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 — 2025-12-23
|
||||
|
||||
### Highlights
|
||||
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
|
||||
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.
|
||||
|
||||
### Changes
|
||||
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
|
||||
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.
|
||||
21
Swabble/LICENSE
Normal file
21
Swabble/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
33
Swabble/Package.resolved
Normal file
33
Swabble/Package.resolved
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||
"version" : "600.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-testing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-testing",
|
||||
"state" : {
|
||||
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
|
||||
"version" : "0.99.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
55
Swabble/Package.swift
Normal file
55
Swabble/Package.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "swabble",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
.iOS(.v17),
|
||||
],
|
||||
products: [
|
||||
.library(name: "Swabble", targets: ["Swabble"]),
|
||||
.library(name: "SwabbleKit", targets: ["SwabbleKit"]),
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Swabble",
|
||||
path: "Sources/SwabbleCore",
|
||||
swiftSettings: []),
|
||||
.target(
|
||||
name: "SwabbleKit",
|
||||
path: "Sources/SwabbleKit",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "SwabbleCLI",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
"SwabbleKit",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
path: "Sources/swabble"),
|
||||
.testTarget(
|
||||
name: "SwabbleKitTests",
|
||||
dependencies: [
|
||||
"SwabbleKit",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "swabbleTests",
|
||||
dependencies: [
|
||||
"Swabble",
|
||||
.product(name: "Testing", package: "swift-testing"),
|
||||
]),
|
||||
],
|
||||
swiftLanguageModes: [.v6])
|
||||
111
Swabble/README.md
Normal file
111
Swabble/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)
|
||||
|
||||
swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.
|
||||
|
||||
- **Local-only**: Speech.framework on-device models; zero network usage.
|
||||
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
|
||||
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
|
||||
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
|
||||
- **Services**: launchd helper stubs for start/stop/install.
|
||||
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).
|
||||
|
||||
## Quick start
|
||||
```bash
|
||||
# Install deps
|
||||
brew install swiftformat swiftlint
|
||||
|
||||
# Build
|
||||
swift build
|
||||
|
||||
# Write default config (~/.config/swabble/config.json)
|
||||
swift run swabble setup
|
||||
|
||||
# Run foreground daemon
|
||||
swift run swabble serve
|
||||
|
||||
# Test your hook
|
||||
swift run swabble test-hook "hello world"
|
||||
|
||||
# Transcribe a file to SRT
|
||||
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
|
||||
```
|
||||
|
||||
## Use as a library
|
||||
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:
|
||||
|
||||
```swift
|
||||
// Package.swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/swabble.git", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MyApp", dependencies: [
|
||||
.product(name: "Swabble", package: "swabble"), // Speech pipeline (macOS 26+ / iOS 26+)
|
||||
.product(name: "SwabbleKit", package: "swabble"), // Wake-word gate utilities (iOS 17+ / macOS 15+)
|
||||
]),
|
||||
]
|
||||
```
|
||||
|
||||
## CLI
|
||||
- `serve` — foreground loop (mic → wake → hook)
|
||||
- `transcribe <file>` — offline transcription (txt|srt)
|
||||
- `test-hook "text"` — invoke configured hook
|
||||
- `mic list|set <index>` — enumerate/select input device
|
||||
- `setup` — write default config JSON
|
||||
- `doctor` — check Speech auth & device availability
|
||||
- `health` — prints `ok`
|
||||
- `tail-log` — last 10 transcripts
|
||||
- `status` — show wake state + recent transcripts
|
||||
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
|
||||
- `start|stop|restart` — placeholders until full launchd wiring
|
||||
|
||||
All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.
|
||||
|
||||
## Config
|
||||
`~/.config/swabble/config.json` (auto-created by `setup`):
|
||||
```json
|
||||
{
|
||||
"audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
|
||||
"wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
|
||||
"hook": {
|
||||
"command": "",
|
||||
"args": [],
|
||||
"prefix": "Voice swabble from ${hostname}: ",
|
||||
"cooldownSeconds": 1,
|
||||
"minCharacters": 24,
|
||||
"timeoutSeconds": 5,
|
||||
"env": {}
|
||||
},
|
||||
"logging": {"level": "info", "format": "text"},
|
||||
"transcripts": {"enabled": true, "maxEntries": 50},
|
||||
"speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
|
||||
}
|
||||
```
|
||||
|
||||
- Config path override: `--config /path/to/config.json` on relevant commands.
|
||||
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.
|
||||
|
||||
## Hook protocol
|
||||
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
|
||||
```
|
||||
<command> <args...> "<prefix><text>"
|
||||
```
|
||||
Environment variables:
|
||||
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
|
||||
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
|
||||
- plus any `hook.env` key/values
|
||||
|
||||
## Speech pipeline
|
||||
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
|
||||
- Requests volatile + final results; the CLI uses text-only wake gating today.
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
- launchd control (load/bootout, PID + status socket)
|
||||
- JSON logging + PII redaction toggle
|
||||
- Stronger wake-word detection and control socket status/health
|
||||
77
Swabble/Sources/SwabbleCore/Config/Config.swift
Normal file
77
Swabble/Sources/SwabbleCore/Config/Config.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
public struct SwabbleConfig: Codable, Sendable {
|
||||
public struct Audio: Codable, Sendable {
|
||||
public var deviceName: String = ""
|
||||
public var deviceIndex: Int = -1
|
||||
public var sampleRate: Double = 16000
|
||||
public var channels: Int = 1
|
||||
}
|
||||
|
||||
public struct Wake: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var word: String = "clawd"
|
||||
public var aliases: [String] = ["claude"]
|
||||
}
|
||||
|
||||
public struct Hook: Codable, Sendable {
|
||||
public var command: String = ""
|
||||
public var args: [String] = []
|
||||
public var prefix: String = "Voice swabble from ${hostname}: "
|
||||
public var cooldownSeconds: Double = 1
|
||||
public var minCharacters: Int = 24
|
||||
public var timeoutSeconds: Double = 5
|
||||
public var env: [String: String] = [:]
|
||||
}
|
||||
|
||||
public struct Logging: Codable, Sendable {
|
||||
public var level: String = "info"
|
||||
public var format: String = "text" // text|json placeholder
|
||||
}
|
||||
|
||||
public struct Transcripts: Codable, Sendable {
|
||||
public var enabled: Bool = true
|
||||
public var maxEntries: Int = 50
|
||||
}
|
||||
|
||||
public struct Speech: Codable, Sendable {
|
||||
public var localeIdentifier: String = Locale.current.identifier
|
||||
public var etiquetteReplacements: Bool = false
|
||||
}
|
||||
|
||||
public var audio = Audio()
|
||||
public var wake = Wake()
|
||||
public var hook = Hook()
|
||||
public var logging = Logging()
|
||||
public var transcripts = Transcripts()
|
||||
public var speech = Speech()
|
||||
|
||||
public static let defaultPath = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".config/swabble/config.json")
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public enum ConfigError: Error {
|
||||
case missingConfig
|
||||
}
|
||||
|
||||
public enum ConfigLoader {
|
||||
public static func load(at path: URL?) throws -> SwabbleConfig {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
throw ConfigError.missingConfig
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(SwabbleConfig.self, from: data)
|
||||
}
|
||||
|
||||
public static func save(_ config: SwabbleConfig, at path: URL?) throws {
|
||||
let url = path ?? SwabbleConfig.defaultPath
|
||||
let dir = url.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(config)
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
75
Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift
Normal file
75
Swabble/Sources/SwabbleCore/Hooks/HookExecutor.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public struct HookJob: Sendable {
|
||||
public let text: String
|
||||
public let timestamp: Date
|
||||
|
||||
public init(text: String, timestamp: Date) {
|
||||
self.text = text
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public actor HookExecutor {
|
||||
private let config: SwabbleConfig
|
||||
private var lastRun: Date?
|
||||
private let hostname: String
|
||||
|
||||
public init(config: SwabbleConfig) {
|
||||
self.config = config
|
||||
hostname = Host.current().localizedName ?? "host"
|
||||
}
|
||||
|
||||
public func shouldRun() -> Bool {
|
||||
guard config.hook.cooldownSeconds > 0 else { return true }
|
||||
if let lastRun, Date().timeIntervalSince(lastRun) < config.hook.cooldownSeconds {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public func run(job: HookJob) async throws {
|
||||
guard shouldRun() else { return }
|
||||
guard !config.hook.command.isEmpty else { throw NSError(
|
||||
domain: "Hook",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "hook command not set"]) }
|
||||
|
||||
let prefix = config.hook.prefix.replacingOccurrences(of: "${hostname}", with: hostname)
|
||||
let payload = prefix + job.text
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: config.hook.command)
|
||||
process.arguments = config.hook.args + [payload]
|
||||
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["SWABBLE_TEXT"] = job.text
|
||||
env["SWABBLE_PREFIX"] = prefix
|
||||
for (k, v) in config.hook.env {
|
||||
env[k] = v
|
||||
}
|
||||
process.environment = env
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
try process.run()
|
||||
|
||||
let timeoutNanos = UInt64(max(config.hook.timeoutSeconds, 0.1) * 1_000_000_000)
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
process.waitUntilExit()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: timeoutNanos)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
lastRun = Date()
|
||||
}
|
||||
}
|
||||
50
Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift
Normal file
50
Swabble/Sources/SwabbleCore/Speech/BufferConverter.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
@preconcurrency import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class BufferConverter {
|
||||
private final class Box<T>: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } }
|
||||
enum ConverterError: Swift.Error {
|
||||
case failedToCreateConverter
|
||||
case failedToCreateConversionBuffer
|
||||
case conversionFailed(NSError?)
|
||||
}
|
||||
|
||||
private var converter: AVAudioConverter?
|
||||
|
||||
func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> AVAudioPCMBuffer {
|
||||
let inputFormat = buffer.format
|
||||
if inputFormat == format {
|
||||
return buffer
|
||||
}
|
||||
if converter == nil || converter?.outputFormat != format {
|
||||
converter = AVAudioConverter(from: inputFormat, to: format)
|
||||
converter?.primeMethod = .none
|
||||
}
|
||||
guard let converter else { throw ConverterError.failedToCreateConverter }
|
||||
|
||||
let sampleRateRatio = converter.outputFormat.sampleRate / converter.inputFormat.sampleRate
|
||||
let scaledInputFrameLength = Double(buffer.frameLength) * sampleRateRatio
|
||||
let frameCapacity = AVAudioFrameCount(scaledInputFrameLength.rounded(.up))
|
||||
guard let conversionBuffer = AVAudioPCMBuffer(pcmFormat: converter.outputFormat, frameCapacity: frameCapacity)
|
||||
else {
|
||||
throw ConverterError.failedToCreateConversionBuffer
|
||||
}
|
||||
|
||||
var nsError: NSError?
|
||||
let consumed = Box(false)
|
||||
let inputBuffer = buffer
|
||||
let status = converter.convert(to: conversionBuffer, error: &nsError) { _, statusPtr in
|
||||
if consumed.value {
|
||||
statusPtr.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
consumed.value = true
|
||||
statusPtr.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
if status == .error {
|
||||
throw ConverterError.conversionFailed(nsError)
|
||||
}
|
||||
return conversionBuffer
|
||||
}
|
||||
}
|
||||
114
Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift
Normal file
114
Swabble/Sources/SwabbleCore/Speech/SpeechPipeline.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public struct SpeechSegment: Sendable {
|
||||
public let text: String
|
||||
public let isFinal: Bool
|
||||
}
|
||||
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public enum SpeechPipelineError: Error {
|
||||
case authorizationDenied
|
||||
case analyzerFormatUnavailable
|
||||
case transcriberUnavailable
|
||||
}
|
||||
|
||||
/// Live microphone → SpeechAnalyzer → SpeechTranscriber pipeline.
|
||||
@available(macOS 26.0, iOS 26.0, *)
|
||||
public actor SpeechPipeline {
|
||||
private struct UnsafeBuffer: @unchecked Sendable { let buffer: AVAudioPCMBuffer }
|
||||
|
||||
private var engine = AVAudioEngine()
|
||||
private var transcriber: SpeechTranscriber?
|
||||
private var analyzer: SpeechAnalyzer?
|
||||
private var inputContinuation: AsyncStream<AnalyzerInput>.Continuation?
|
||||
private var resultTask: Task<Void, Never>?
|
||||
private let converter = BufferConverter()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start(localeIdentifier: String, etiquette: Bool) async throws -> AsyncStream<SpeechSegment> {
|
||||
let auth = await requestAuthorizationIfNeeded()
|
||||
guard auth == .authorized else { throw SpeechPipelineError.authorizationDenied }
|
||||
|
||||
let transcriberModule = SpeechTranscriber(
|
||||
locale: Locale(identifier: localeIdentifier),
|
||||
transcriptionOptions: etiquette ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [.volatileResults],
|
||||
attributeOptions: [])
|
||||
transcriber = transcriberModule
|
||||
|
||||
guard let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriberModule])
|
||||
else {
|
||||
throw SpeechPipelineError.analyzerFormatUnavailable
|
||||
}
|
||||
|
||||
analyzer = SpeechAnalyzer(modules: [transcriberModule])
|
||||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||||
inputContinuation = continuation
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let inputFormat = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
let boxed = UnsafeBuffer(buffer: buffer)
|
||||
Task { await self.handleBuffer(boxed.buffer, targetFormat: analyzerFormat) }
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
try await analyzer?.start(inputSequence: stream)
|
||||
|
||||
guard let transcriberForStream = transcriber else {
|
||||
throw SpeechPipelineError.transcriberUnavailable
|
||||
}
|
||||
|
||||
return AsyncStream { continuation in
|
||||
self.resultTask = Task {
|
||||
do {
|
||||
for try await result in transcriberForStream.results {
|
||||
let seg = SpeechSegment(text: String(result.text.characters), isFinal: result.isFinal)
|
||||
continuation.yield(seg)
|
||||
}
|
||||
} catch {
|
||||
// swallow errors and finish
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
Task { await self.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async {
|
||||
resultTask?.cancel()
|
||||
inputContinuation?.finish()
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
try? await analyzer?.finalizeAndFinishThroughEndOfInput()
|
||||
}
|
||||
|
||||
private func handleBuffer(_ buffer: AVAudioPCMBuffer, targetFormat: AVAudioFormat) async {
|
||||
do {
|
||||
let converted = try converter.convert(buffer, to: targetFormat)
|
||||
let input = AnalyzerInput(buffer: converted)
|
||||
inputContinuation?.yield(input)
|
||||
} catch {
|
||||
// drop on conversion failure
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAuthorizationIfNeeded() async -> SFSpeechRecognizerAuthorizationStatus {
|
||||
let current = SFSpeechRecognizer.authorizationStatus()
|
||||
guard current == .notDetermined else { return current }
|
||||
return await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import NaturalLanguage
|
||||
|
||||
extension AttributedString {
|
||||
public func sentences(maxLength: Int? = nil) -> [AttributedString] {
|
||||
let tokenizer = NLTokenizer(unit: .sentence)
|
||||
let string = String(characters)
|
||||
tokenizer.string = string
|
||||
let sentenceRanges = tokenizer.tokens(for: string.startIndex..<string.endIndex).map {
|
||||
(
|
||||
$0,
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!)
|
||||
}
|
||||
let ranges = sentenceRanges.flatMap { sentenceStringRange, sentenceRange in
|
||||
let sentence = self[sentenceRange]
|
||||
guard let maxLength, sentence.characters.count > maxLength else {
|
||||
return [sentenceRange]
|
||||
}
|
||||
|
||||
let wordTokenizer = NLTokenizer(unit: .word)
|
||||
wordTokenizer.string = string
|
||||
var wordRanges = wordTokenizer.tokens(for: sentenceStringRange).map {
|
||||
AttributedString.Index($0.lowerBound, within: self)!
|
||||
..<
|
||||
AttributedString.Index($0.upperBound, within: self)!
|
||||
}
|
||||
guard !wordRanges.isEmpty else { return [sentenceRange] }
|
||||
wordRanges[0] = sentenceRange.lowerBound..<wordRanges[0].upperBound
|
||||
wordRanges[wordRanges.count - 1] = wordRanges[wordRanges.count - 1].lowerBound..<sentenceRange.upperBound
|
||||
|
||||
var ranges: [Range<AttributedString.Index>] = []
|
||||
for wordRange in wordRanges {
|
||||
if let lastRange = ranges.last,
|
||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||
{
|
||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||
} else {
|
||||
ranges.append(wordRange)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
return ranges.compactMap { range in
|
||||
let audioTimeRanges = self[range].runs.filter {
|
||||
!String(self[$0.range].characters)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}.compactMap(\.audioTimeRange)
|
||||
guard !audioTimeRanges.isEmpty else { return nil }
|
||||
let start = audioTimeRanges.first!.start
|
||||
let end = audioTimeRanges.last!.end
|
||||
var attributes = AttributeContainer()
|
||||
attributes[AttributeScopes.SpeechAttributes.TimeRangeAttribute.self] = CMTimeRange(
|
||||
start: start,
|
||||
end: end)
|
||||
return AttributedString(self[range].characters, attributes: attributes)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Swabble/Sources/SwabbleCore/Support/Logging.swift
Normal file
41
Swabble/Sources/SwabbleCore/Support/Logging.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
public enum LogLevel: String, Comparable, CaseIterable, Sendable {
|
||||
case trace, debug, info, warn, error
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .trace: 0
|
||||
case .debug: 1
|
||||
case .info: 2
|
||||
case .warn: 3
|
||||
case .error: 4
|
||||
}
|
||||
}
|
||||
|
||||
public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { lhs.rank < rhs.rank }
|
||||
}
|
||||
|
||||
public struct Logger: Sendable {
|
||||
public let level: LogLevel
|
||||
|
||||
public init(level: LogLevel) { self.level = level }
|
||||
|
||||
public func log(_ level: LogLevel, _ message: String) {
|
||||
guard level >= self.level else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
print("[\(level.rawValue.uppercased())] \(ts) | \(message)")
|
||||
}
|
||||
|
||||
public func trace(_ msg: String) { log(.trace, msg) }
|
||||
public func debug(_ msg: String) { log(.debug, msg) }
|
||||
public func info(_ msg: String) { log(.info, msg) }
|
||||
public func warn(_ msg: String) { log(.warn, msg) }
|
||||
public func error(_ msg: String) { log(.error, msg) }
|
||||
}
|
||||
|
||||
extension LogLevel {
|
||||
public init?(configValue: String) {
|
||||
self.init(rawValue: configValue.lowercased())
|
||||
}
|
||||
}
|
||||
45
Swabble/Sources/SwabbleCore/Support/OutputFormat.swift
Normal file
45
Swabble/Sources/SwabbleCore/Support/OutputFormat.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
public enum OutputFormat: String {
|
||||
case txt
|
||||
case srt
|
||||
|
||||
public var needsAudioTimeRange: Bool {
|
||||
switch self {
|
||||
case .srt: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
public func text(for transcript: AttributedString, maxLength: Int) -> String {
|
||||
switch self {
|
||||
case .txt:
|
||||
return String(transcript.characters)
|
||||
case .srt:
|
||||
func format(_ timeInterval: TimeInterval) -> String {
|
||||
let ms = Int(timeInterval.truncatingRemainder(dividingBy: 1) * 1000)
|
||||
let s = Int(timeInterval) % 60
|
||||
let m = (Int(timeInterval) / 60) % 60
|
||||
let h = Int(timeInterval) / 60 / 60
|
||||
return String(format: "%0.2d:%0.2d:%0.2d,%0.3d", h, m, s, ms)
|
||||
}
|
||||
|
||||
return transcript.sentences(maxLength: maxLength).compactMap { (sentence: AttributedString) -> (
|
||||
CMTimeRange,
|
||||
String)? in
|
||||
guard let timeRange = sentence.audioTimeRange else { return nil }
|
||||
return (timeRange, String(sentence.characters))
|
||||
}.enumerated().map { index, run in
|
||||
let (timeRange, text) = run
|
||||
return """
|
||||
|
||||
\(index + 1)
|
||||
\(format(timeRange.start.seconds)) --> \(format(timeRange.end.seconds))
|
||||
\(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
|
||||
"""
|
||||
}.joined().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift
Normal file
46
Swabble/Sources/SwabbleCore/Support/TranscriptsStore.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public actor TranscriptsStore {
|
||||
public static let shared = TranscriptsStore()
|
||||
|
||||
private var entries: [String] = []
|
||||
private let limit = 100
|
||||
private let fileURL: URL
|
||||
|
||||
public init() {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/swabble", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||
if let data = try? Data(contentsOf: fileURL),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func append(text: String) {
|
||||
entries.append(text)
|
||||
if entries.count > limit {
|
||||
entries.removeFirst(entries.count - limit)
|
||||
}
|
||||
let body = entries.joined(separator: "\n")
|
||||
try? body.write(to: fileURL, atomically: false, encoding: .utf8)
|
||||
}
|
||||
|
||||
public func latest() -> [String] { entries }
|
||||
}
|
||||
|
||||
extension String {
|
||||
private func appendLine(to url: URL) throws {
|
||||
let data = (self + "\n").data(using: .utf8) ?? Data()
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: data)
|
||||
try handle.close()
|
||||
} else {
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
Swabble/Sources/SwabbleKit/WakeWordGate.swift
Normal file
192
Swabble/Sources/SwabbleKit/WakeWordGate.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
|
||||
public struct WakeWordSegment: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let start: TimeInterval
|
||||
public let duration: TimeInterval
|
||||
public let range: Range<String.Index>?
|
||||
|
||||
public init(text: String, start: TimeInterval, duration: TimeInterval, range: Range<String.Index>? = nil) {
|
||||
self.text = text
|
||||
self.start = start
|
||||
self.duration = duration
|
||||
self.range = range
|
||||
}
|
||||
|
||||
public var end: TimeInterval { self.start + self.duration }
|
||||
}
|
||||
|
||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||
public var triggers: [String]
|
||||
public var minPostTriggerGap: TimeInterval
|
||||
public var minCommandLength: Int
|
||||
|
||||
public init(
|
||||
triggers: [String],
|
||||
minPostTriggerGap: TimeInterval = 0.45,
|
||||
minCommandLength: Int = 1)
|
||||
{
|
||||
self.triggers = triggers
|
||||
self.minPostTriggerGap = minPostTriggerGap
|
||||
self.minCommandLength = minCommandLength
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeWordGateMatch: Sendable, Equatable {
|
||||
public let triggerEndTime: TimeInterval
|
||||
public let postGap: TimeInterval
|
||||
public let command: String
|
||||
|
||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
||||
self.triggerEndTime = triggerEndTime
|
||||
self.postGap = postGap
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public enum WakeWordGate {
|
||||
private struct Token {
|
||||
let normalized: String
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
let range: Range<String.Index>?
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct TriggerTokens {
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
public static func match(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
config: WakeWordGateConfig)
|
||||
-> WakeWordGateMatch? {
|
||||
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||
guard !triggerTokens.isEmpty else { return nil }
|
||||
|
||||
let tokens = self.normalizeSegments(segments)
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
|
||||
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||
|
||||
for trigger in triggerTokens {
|
||||
let count = trigger.tokens.count
|
||||
guard count > 0, tokens.count > count else { continue }
|
||||
for i in 0...(tokens.count - count - 1) {
|
||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
||||
if !matched { continue }
|
||||
|
||||
let triggerEnd = tokens[i + count - 1].end
|
||||
let nextToken = tokens[i + count]
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = (i, triggerEnd, gap)
|
||||
}
|
||||
}
|
||||
|
||||
guard let best else { return nil }
|
||||
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
}
|
||||
|
||||
public static func commandText(
|
||||
transcript: String,
|
||||
segments: [WakeWordSegment],
|
||||
triggerEndTime: TimeInterval)
|
||||
-> String {
|
||||
let threshold = triggerEndTime + 0.001
|
||||
for segment in segments where segment.start >= threshold {
|
||||
if normalizeToken(segment.text).isEmpty { continue }
|
||||
if let range = segment.range {
|
||||
let slice = transcript[range.lowerBound...]
|
||||
return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
let text = segments
|
||||
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
public static func matchesTextOnly(text: String, triggers: [String]) -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let normalized = text.lowercased()
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||
if token.isEmpty { continue }
|
||||
if normalized.contains(token) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||
var out = text
|
||||
for trigger in triggers {
|
||||
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
guard !token.isEmpty else { continue }
|
||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||
}
|
||||
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
}
|
||||
|
||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||
var output: [TriggerTokens] = []
|
||||
for trigger in triggers {
|
||||
let tokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(tokens: tokens))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||
segments.compactMap { segment in
|
||||
let normalized = self.normalizeToken(segment.text)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return Token(
|
||||
normalized: normalized,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
range: segment.range,
|
||||
text: segment.text)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeToken(_ token: String) -> String {
|
||||
token
|
||||
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
||||
.union(.punctuationCharacters)
|
||||
}
|
||||
|
||||
#if canImport(Speech)
|
||||
import Speech
|
||||
|
||||
public enum WakeWordSpeechSegments {
|
||||
public static func from(transcription: SFTranscription, transcript: String) -> [WakeWordSegment] {
|
||||
transcription.segments.map { segment in
|
||||
let range = Range(segment.substringRange, in: transcript)
|
||||
return WakeWordSegment(
|
||||
text: segment.substring,
|
||||
start: segment.timestamp,
|
||||
duration: segment.duration,
|
||||
range: range)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
71
Swabble/Sources/swabble/CLI/CLIRegistry.swift
Normal file
71
Swabble/Sources/swabble/CLI/CLIRegistry.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
enum CLIRegistry {
|
||||
static var descriptors: [CommandDescriptor] {
|
||||
let serveDesc = descriptor(for: ServeCommand.self)
|
||||
let transcribeDesc = descriptor(for: TranscribeCommand.self)
|
||||
let testHookDesc = descriptor(for: TestHookCommand.self)
|
||||
let micList = descriptor(for: MicList.self)
|
||||
let micSet = descriptor(for: MicSet.self)
|
||||
let micRoot = CommandDescriptor(
|
||||
name: "mic",
|
||||
abstract: "Microphone management",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [micList, micSet])
|
||||
let serviceRoot = CommandDescriptor(
|
||||
name: "service",
|
||||
abstract: "launchd helper",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: [
|
||||
descriptor(for: ServiceInstall.self),
|
||||
descriptor(for: ServiceUninstall.self),
|
||||
descriptor(for: ServiceStatus.self),
|
||||
])
|
||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||
let setupDesc = descriptor(for: SetupCommand.self)
|
||||
let healthDesc = descriptor(for: HealthCommand.self)
|
||||
let tailLogDesc = descriptor(for: TailLogCommand.self)
|
||||
let startDesc = descriptor(for: StartCommand.self)
|
||||
let stopDesc = descriptor(for: StopCommand.self)
|
||||
let restartDesc = descriptor(for: RestartCommand.self)
|
||||
let statusDesc = descriptor(for: StatusCommand.self)
|
||||
|
||||
let rootSignature = CommandSignature().withStandardRuntimeFlags()
|
||||
let root = CommandDescriptor(
|
||||
name: "swabble",
|
||||
abstract: "Speech hook daemon",
|
||||
discussion: "Local wake-word → SpeechTranscriber → hook",
|
||||
signature: rootSignature,
|
||||
subcommands: [
|
||||
serveDesc,
|
||||
transcribeDesc,
|
||||
testHookDesc,
|
||||
micRoot,
|
||||
serviceRoot,
|
||||
doctorDesc,
|
||||
setupDesc,
|
||||
healthDesc,
|
||||
tailLogDesc,
|
||||
startDesc,
|
||||
stopDesc,
|
||||
restartDesc,
|
||||
statusDesc,
|
||||
])
|
||||
return [root]
|
||||
}
|
||||
|
||||
private static func descriptor(for type: any ParsableCommand.Type) -> CommandDescriptor {
|
||||
let sig = CommandSignature.describe(type.init()).withStandardRuntimeFlags()
|
||||
return CommandDescriptor(
|
||||
name: type.commandDescription.commandName ?? "",
|
||||
abstract: type.commandDescription.abstract,
|
||||
discussion: type.commandDescription.discussion,
|
||||
signature: sig,
|
||||
subcommands: [])
|
||||
}
|
||||
}
|
||||
37
Swabble/Sources/swabble/Commands/DoctorCommand.swift
Normal file
37
Swabble/Sources/swabble/Commands/DoctorCommand.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct DoctorCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "doctor", abstract: "Check Speech permission and config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let auth = await SFSpeechRecognizer.authorizationStatus()
|
||||
print("Speech auth: \(auth)")
|
||||
do {
|
||||
_ = try ConfigLoader.load(at: configURL)
|
||||
print("Config: OK")
|
||||
} catch {
|
||||
print("Config missing or invalid; run setup")
|
||||
}
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
print("Mics found: \(session.devices.count)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
16
Swabble/Sources/swabble/Commands/HealthCommand.swift
Normal file
16
Swabble/Sources/swabble/Commands/HealthCommand.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct HealthCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "health", abstract: "Health probe")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("ok")
|
||||
}
|
||||
}
|
||||
62
Swabble/Sources/swabble/Commands/MicCommands.swift
Normal file
62
Swabble/Sources/swabble/Commands/MicCommands.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct MicCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "mic",
|
||||
abstract: "Microphone management",
|
||||
subcommands: [MicList.self, MicSet.self])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicList: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "list", abstract: "List input devices")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.microphone, .external],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
let devices = session.devices
|
||||
if devices.isEmpty { print("no audio inputs found"); return }
|
||||
for (idx, device) in devices.enumerated() {
|
||||
print("[\(idx)] \(device.localizedName)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MicSet: ParsableCommand {
|
||||
@Argument(help: "Device index from list") var index: Int = 0
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "set", abstract: "Set default input device index")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let value = parsed.positional.first, let intVal = Int(value) { index = intVal }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg = try ConfigLoader.load(at: configURL)
|
||||
cfg.audio.deviceIndex = index
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("saved device index \(index)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
81
Swabble/Sources/swabble/Commands/ServeCommand.swift
Normal file
81
Swabble/Sources/swabble/Commands/ServeCommand.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
import SwabbleKit
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
struct ServeCommand: ParsableCommand {
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
@Flag(name: .long("no-wake"), help: "Disable wake word") var noWake: Bool = false
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "serve",
|
||||
abstract: "Run swabble in the foreground")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if parsed.flags.contains("noWake") { noWake = true }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
var cfg: SwabbleConfig
|
||||
do {
|
||||
cfg = try ConfigLoader.load(at: configURL)
|
||||
} catch {
|
||||
cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
}
|
||||
if noWake {
|
||||
cfg.wake.enabled = false
|
||||
}
|
||||
|
||||
let logger = Logger(level: LogLevel(configValue: cfg.logging.level) ?? .info)
|
||||
logger.info("swabble serve starting (wake: \(cfg.wake.enabled ? cfg.wake.word : "disabled"))")
|
||||
let pipeline = SpeechPipeline()
|
||||
do {
|
||||
let stream = try await pipeline.start(
|
||||
localeIdentifier: cfg.speech.localeIdentifier,
|
||||
etiquette: cfg.speech.etiquetteReplacements)
|
||||
for await seg in stream {
|
||||
if cfg.wake.enabled {
|
||||
guard Self.matchesWake(text: seg.text, cfg: cfg) else { continue }
|
||||
}
|
||||
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
|
||||
let job = HookJob(text: stripped, timestamp: Date())
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: job)
|
||||
if cfg.transcripts.enabled {
|
||||
await TranscriptsStore.shared.append(text: stripped)
|
||||
}
|
||||
if seg.isFinal {
|
||||
logger.info("final: \(stripped)")
|
||||
} else {
|
||||
logger.debug("partial: \(stripped)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("serve error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? {
|
||||
configPath.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
|
||||
private static func matchesWake(text: String, cfg: SwabbleConfig) -> Bool {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.matchesTextOnly(text: text, triggers: triggers)
|
||||
}
|
||||
|
||||
private static func stripWake(text: String, cfg: SwabbleConfig) -> String {
|
||||
let triggers = [cfg.wake.word] + cfg.wake.aliases
|
||||
return WakeWordGate.stripWake(text: text, triggers: triggers)
|
||||
}
|
||||
}
|
||||
77
Swabble/Sources/swabble/Commands/ServiceCommands.swift
Normal file
77
Swabble/Sources/swabble/Commands/ServiceCommands.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct ServiceRootCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "service",
|
||||
abstract: "Manage launchd agent",
|
||||
subcommands: [ServiceInstall.self, ServiceUninstall.self, ServiceStatus.self])
|
||||
}
|
||||
}
|
||||
|
||||
private enum LaunchdHelper {
|
||||
static let label = "com.swabble.agent"
|
||||
|
||||
static var plistURL: URL {
|
||||
FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||
}
|
||||
|
||||
static func writePlist(executable: String) throws {
|
||||
let plist: [String: Any] = [
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "serve"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: plistURL)
|
||||
}
|
||||
|
||||
static func removePlist() throws {
|
||||
try? FileManager.default.removeItem(at: plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceInstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "install", abstract: "Install user launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let exe = CommandLine.arguments.first ?? "/usr/local/bin/swabble"
|
||||
try LaunchdHelper.writePlist(executable: exe)
|
||||
print("launchctl load -w \(LaunchdHelper.plistURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceUninstall: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "uninstall", abstract: "Remove launch agent")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
try LaunchdHelper.removePlist()
|
||||
print("launchctl bootout gui/$(id -u)/\(LaunchdHelper.label)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ServiceStatus: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show launch agent status")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
if FileManager.default.fileExists(atPath: LaunchdHelper.plistURL.path) {
|
||||
print("plist present at \(LaunchdHelper.plistURL.path)")
|
||||
} else {
|
||||
print("launchd plist not installed")
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Swabble/Sources/swabble/Commands/SetupCommand.swift
Normal file
26
Swabble/Sources/swabble/Commands/SetupCommand.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct SetupCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "setup", abstract: "Write default config")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = SwabbleConfig()
|
||||
try ConfigLoader.save(cfg, at: configURL)
|
||||
print("wrote config to \(configURL?.path ?? SwabbleConfig.defaultPath.path)")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
35
Swabble/Sources/swabble/Commands/StartStopCommands.swift
Normal file
35
Swabble/Sources/swabble/Commands/StartStopCommands.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct StartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "start", abstract: "Start swabble (foreground placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("start: launchd helper not implemented; run 'swabble serve' instead")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct StopCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "stop", abstract: "Stop swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("stop: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RestartCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "restart", abstract: "Restart swabble (placeholder)")
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
print("restart: launchd helper not implemented yet")
|
||||
}
|
||||
}
|
||||
34
Swabble/Sources/swabble/Commands/StatusCommand.swift
Normal file
34
Swabble/Sources/swabble/Commands/StatusCommand.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct StatusCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "status", abstract: "Show daemon state")
|
||||
}
|
||||
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try? ConfigLoader.load(at: configURL)
|
||||
let wake = cfg?.wake.word ?? "clawd"
|
||||
let wakeEnabled = cfg?.wake.enabled ?? false
|
||||
let latest = await TranscriptsStore.shared.latest().suffix(3)
|
||||
print("wake: \(wakeEnabled ? wake : "disabled")")
|
||||
if latest.isEmpty {
|
||||
print("transcripts: (none yet)")
|
||||
} else {
|
||||
print("last transcripts:")
|
||||
latest.forEach { print("- \($0)") }
|
||||
}
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
20
Swabble/Sources/swabble/Commands/TailLogCommand.swift
Normal file
20
Swabble/Sources/swabble/Commands/TailLogCommand.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TailLogCommand: ParsableCommand {
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "tail-log", abstract: "Tail recent transcripts")
|
||||
}
|
||||
|
||||
init() {}
|
||||
init(parsed: ParsedValues) {}
|
||||
|
||||
mutating func run() async throws {
|
||||
let latest = await TranscriptsStore.shared.latest()
|
||||
for line in latest.suffix(10) {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Swabble/Sources/swabble/Commands/TestHookCommand.swift
Normal file
30
Swabble/Sources/swabble/Commands/TestHookCommand.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TestHookCommand: ParsableCommand {
|
||||
@Argument(help: "Text to send to hook") var text: String
|
||||
@Option(name: .long("config"), help: "Path to config JSON") var configPath: String?
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(commandName: "test-hook", abstract: "Invoke the configured hook with text")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { text = positional }
|
||||
if let cfg = parsed.options["config"]?.last { configPath = cfg }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let cfg = try ConfigLoader.load(at: configURL)
|
||||
let executor = HookExecutor(config: cfg)
|
||||
try await executor.run(job: HookJob(text: text, timestamp: Date()))
|
||||
print("hook invoked")
|
||||
}
|
||||
|
||||
private var configURL: URL? { configPath.map { URL(fileURLWithPath: $0) } }
|
||||
}
|
||||
61
Swabble/Sources/swabble/Commands/TranscribeCommand.swift
Normal file
61
Swabble/Sources/swabble/Commands/TranscribeCommand.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import AVFoundation
|
||||
import Commander
|
||||
import Foundation
|
||||
import Speech
|
||||
import Swabble
|
||||
|
||||
@MainActor
|
||||
struct TranscribeCommand: ParsableCommand {
|
||||
@Argument(help: "Path to audio/video file") var inputFile: String = ""
|
||||
@Option(name: .long("locale"), help: "Locale identifier", parsing: .singleValue) var locale: String = Locale.current
|
||||
.identifier
|
||||
@Flag(help: "Censor etiquette-sensitive content") var censor: Bool = false
|
||||
@Option(name: .long("output"), help: "Output file path") var outputFile: String?
|
||||
@Option(name: .long("format"), help: "Output format txt|srt") var format: String = "txt"
|
||||
@Option(name: .long("max-length"), help: "Max sentence length for srt") var maxLength: Int = 40
|
||||
|
||||
static var commandDescription: CommandDescription {
|
||||
CommandDescription(
|
||||
commandName: "transcribe",
|
||||
abstract: "Transcribe a media file locally")
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
init(parsed: ParsedValues) {
|
||||
self.init()
|
||||
if let positional = parsed.positional.first { inputFile = positional }
|
||||
if let loc = parsed.options["locale"]?.last { locale = loc }
|
||||
if parsed.flags.contains("censor") { censor = true }
|
||||
if let out = parsed.options["output"]?.last { outputFile = out }
|
||||
if let fmt = parsed.options["format"]?.last { format = fmt }
|
||||
if let len = parsed.options["maxLength"]?.last, let intVal = Int(len) { maxLength = intVal }
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let fileURL = URL(fileURLWithPath: inputFile)
|
||||
let audioFile = try AVAudioFile(forReading: fileURL)
|
||||
|
||||
let outputFormat = OutputFormat(rawValue: format) ?? .txt
|
||||
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: Locale(identifier: locale),
|
||||
transcriptionOptions: censor ? [.etiquetteReplacements] : [],
|
||||
reportingOptions: [],
|
||||
attributeOptions: outputFormat.needsAudioTimeRange ? [.audioTimeRange] : [])
|
||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true)
|
||||
|
||||
var transcript: AttributedString = ""
|
||||
for try await result in transcriber.results {
|
||||
transcript += result.text
|
||||
}
|
||||
|
||||
let output = outputFormat.text(for: transcript, maxLength: maxLength)
|
||||
if let path = outputFile {
|
||||
try output.write(to: URL(fileURLWithPath: path), atomically: false, encoding: .utf8)
|
||||
} else {
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Swabble/Sources/swabble/main.swift
Normal file
106
Swabble/Sources/swabble/main.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func runCLI() async -> Int32 {
|
||||
do {
|
||||
let descriptors = CLIRegistry.descriptors
|
||||
let program = Program(descriptors: descriptors)
|
||||
let invocation = try program.resolve(argv: CommandLine.arguments)
|
||||
try await dispatch(invocation: invocation)
|
||||
return 0
|
||||
} catch {
|
||||
fputs("error: \(error)\n", stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
@MainActor
|
||||
private func dispatch(invocation: CommandInvocation) async throws {
|
||||
let parsed = invocation.parsedValues
|
||||
let path = invocation.path
|
||||
guard let first = path.first else { throw CommanderProgramError.missingCommand }
|
||||
|
||||
switch first {
|
||||
case "swabble":
|
||||
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||
let sub = path[1]
|
||||
switch sub {
|
||||
case "serve":
|
||||
var cmd = ServeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "transcribe":
|
||||
var cmd = TranscribeCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "test-hook":
|
||||
var cmd = TestHookCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "mic":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||
let micSub = path[2]
|
||||
if micSub == "list" {
|
||||
var cmd = MicList(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else if micSub == "set" {
|
||||
var cmd = MicSet(parsed: parsed)
|
||||
try await cmd.run()
|
||||
} else {
|
||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||
}
|
||||
case "service":
|
||||
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||
let svcSub = path[2]
|
||||
switch svcSub {
|
||||
case "install":
|
||||
var cmd = ServiceInstall()
|
||||
try await cmd.run()
|
||||
case "uninstall":
|
||||
var cmd = ServiceUninstall()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = ServiceStatus()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||
}
|
||||
case "doctor":
|
||||
var cmd = DoctorCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "setup":
|
||||
var cmd = SetupCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "health":
|
||||
var cmd = HealthCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "tail-log":
|
||||
var cmd = TailLogCommand(parsed: parsed)
|
||||
try await cmd.run()
|
||||
case "start":
|
||||
var cmd = StartCommand()
|
||||
try await cmd.run()
|
||||
case "stop":
|
||||
var cmd = StopCommand()
|
||||
try await cmd.run()
|
||||
case "restart":
|
||||
var cmd = RestartCommand()
|
||||
try await cmd.run()
|
||||
case "status":
|
||||
var cmd = StatusCommand()
|
||||
try await cmd.run()
|
||||
default:
|
||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||
}
|
||||
default:
|
||||
throw CommanderProgramError.unknownCommand(first)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
let exitCode = await runCLI()
|
||||
exit(exitCode)
|
||||
} else {
|
||||
fputs("error: swabble requires macOS 26 or newer\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
63
Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift
Normal file
63
Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import SwabbleKit
|
||||
|
||||
@Suite struct WakeWordGateTests {
|
||||
@Test func matchRequiresGapAfterTrigger() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config) == nil)
|
||||
}
|
||||
|
||||
@Test func matchAllowsGapAndExtractsCommand() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do thing")
|
||||
}
|
||||
|
||||
@Test func matchHandlesMultiWordTriggers() {
|
||||
let transcript = "hey clawd do it"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.8, 0.1),
|
||||
("it", 1.0, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["hey clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSegments(
|
||||
transcript: String,
|
||||
words: [(String, TimeInterval, TimeInterval)])
|
||||
-> [WakeWordSegment] {
|
||||
var searchStart = transcript.startIndex
|
||||
var output: [WakeWordSegment] = []
|
||||
for (word, start, duration) in words {
|
||||
let range = transcript.range(of: word, range: searchStart..<transcript.endIndex)
|
||||
output.append(WakeWordSegment(text: word, start: start, duration: duration, range: range))
|
||||
if let range { searchStart = range.upperBound }
|
||||
}
|
||||
return output
|
||||
}
|
||||
23
Swabble/Tests/swabbleTests/ConfigTests.swift
Normal file
23
Swabble/Tests/swabbleTests/ConfigTests.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Swabble
|
||||
|
||||
@Test
|
||||
func configRoundTrip() throws {
|
||||
var cfg = SwabbleConfig()
|
||||
cfg.wake.word = "robot"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
try ConfigLoader.save(cfg, at: url)
|
||||
let loaded = try ConfigLoader.load(at: url)
|
||||
#expect(loaded.wake.word == "robot")
|
||||
#expect(loaded.hook.prefix.contains("Voice swabble"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func configMissingThrows() {
|
||||
#expect(throws: ConfigError.missingConfig) {
|
||||
_ = try ConfigLoader.load(at: FileManager.default.temporaryDirectory.appendingPathComponent("nope.json"))
|
||||
}
|
||||
}
|
||||
33
Swabble/docs/spec.md
Normal file
33
Swabble/docs/spec.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# swabble — macOS 26 speech hook daemon (Swift 6.2)
|
||||
|
||||
Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framework (SpeechAnalyzer + SpeechTranscriber) instead of whisper.cpp. Local-only, wake word gated, dispatches a shell hook with the transcript. Shared wake-gate utilities live in `SwabbleKit` for reuse by other apps (iOS/macOS).
|
||||
|
||||
## Requirements
|
||||
- macOS 26+, Swift 6.2, Speech.framework with on-device assets.
|
||||
- Local only; no network calls during transcription.
|
||||
- Wake word gating (default "clawd" plus aliases) with bypass flag `--no-wake`.
|
||||
- `SwabbleKit` target (multi-platform) providing wake-word gating helpers that can use speech segment timing to require a post-trigger gap.
|
||||
- Hook execution with cooldown, min_chars, timeout, prefix, env vars.
|
||||
- Simple config at `~/.config/swabble/config.json` (JSON, Codable) — no TOML.
|
||||
- CLI implemented with Commander (SwiftPM package `steipete/Commander`); core types are available via the SwiftPM library product `Swabble` for embedding.
|
||||
- Foreground `serve`; later launchd helper for start/stop/restart.
|
||||
- File transcription command emitting txt or srt.
|
||||
- Basic status/health surfaces and mic selection stubs.
|
||||
|
||||
## Architecture
|
||||
- **CLI layer (Commander)**: Root command `swabble` with subcommands `serve`, `transcribe`, `test-hook`, `mic list|set`, `doctor`, `health`, `tail-log`. Runtime flags from Commander (`-v/--verbose`, `--json-output`, `--log-level`). Custom `--config` path applies everywhere.
|
||||
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
|
||||
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
|
||||
- **Wake gate**: CLI currently uses text-only keyword match; shared `SwabbleKit` gate can enforce a minimum pause between the wake word and the next token when speech segments are available. `--no-wake` disables gating.
|
||||
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
|
||||
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
|
||||
- **Logging**: simple structured logger to stderr; respects log level.
|
||||
|
||||
## Out of scope (initial cut)
|
||||
- Model management (Speech handles assets).
|
||||
- Launchd helper (planned follow-up).
|
||||
- Advanced wake-word detector (segment-aware gate now lives in `SwabbleKit`; CLI still text-only until segment timing is plumbed through).
|
||||
|
||||
## Open decisions
|
||||
- Whether to expose a UNIX control socket for `status`/`health` (currently planned as stdin/out direct calls).
|
||||
- Hook redaction (PII) parity with brabble — placeholder boolean, no implementation yet.
|
||||
10
Swabble/scripts/format.sh
Executable file
10
Swabble/scripts/format.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
||||
else
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
fi
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
14
Swabble/scripts/lint.sh
Executable file
14
Swabble/scripts/lint.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
||||
else
|
||||
CONFIG="$ROOT/.swiftlint.yml"
|
||||
fi
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
swiftlint --config "$CONFIG"
|
||||
212
appcast.xml
Normal file
212
appcast.xml
Normal file
@@ -0,0 +1,212 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
5
apps/android/.gitignore
vendored
Normal file
5
apps/android/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
51
apps/android/README.md
Normal file
51
apps/android/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-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 clawdbot 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
|
||||
clawdbot nodes pending
|
||||
clawdbot 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`
|
||||
109
apps/android/app/build.gradle.kts
Normal file
109
apps/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,109 @@
|
||||
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.clawdbot.android"
|
||||
compileSdk = 36
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "2.0.0-beta3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
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.2")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
|
||||
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")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
49
apps/android/app/src/main/AndroidManifest.xml
Normal file
49
apps/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".NodeApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
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.ClawdbotNode">
|
||||
<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>
|
||||
197
apps/android/app/src/main/assets/tool-display.json
Normal file
197
apps/android/app/src/main/assets/tool-display.json
Normal file
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fallback": {
|
||||
"emoji": "🧩",
|
||||
"detailKeys": [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId"
|
||||
]
|
||||
},
|
||||
"tools": {
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Bash",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
"detailKeys": ["sessionId"]
|
||||
},
|
||||
"read": {
|
||||
"emoji": "📖",
|
||||
"title": "Read",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"write": {
|
||||
"emoji": "✍️",
|
||||
"title": "Write",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"edit": {
|
||||
"emoji": "📝",
|
||||
"title": "Edit",
|
||||
"detailKeys": ["path"]
|
||||
},
|
||||
"attach": {
|
||||
"emoji": "📎",
|
||||
"title": "Attach",
|
||||
"detailKeys": ["path", "url", "fileName"]
|
||||
},
|
||||
"browser": {
|
||||
"emoji": "🌐",
|
||||
"title": "Browser",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"start": { "label": "start" },
|
||||
"stop": { "label": "stop" },
|
||||
"tabs": { "label": "tabs" },
|
||||
"open": { "label": "open", "detailKeys": ["targetUrl"] },
|
||||
"focus": { "label": "focus", "detailKeys": ["targetId"] },
|
||||
"close": { "label": "close", "detailKeys": ["targetId"] },
|
||||
"snapshot": {
|
||||
"label": "snapshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element", "format"]
|
||||
},
|
||||
"screenshot": {
|
||||
"label": "screenshot",
|
||||
"detailKeys": ["targetUrl", "targetId", "ref", "element"]
|
||||
},
|
||||
"navigate": {
|
||||
"label": "navigate",
|
||||
"detailKeys": ["targetUrl", "targetId"]
|
||||
},
|
||||
"console": { "label": "console", "detailKeys": ["level", "targetId"] },
|
||||
"pdf": { "label": "pdf", "detailKeys": ["targetId"] },
|
||||
"upload": {
|
||||
"label": "upload",
|
||||
"detailKeys": ["paths", "ref", "inputRef", "element", "targetId"]
|
||||
},
|
||||
"dialog": {
|
||||
"label": "dialog",
|
||||
"detailKeys": ["accept", "promptText", "targetId"]
|
||||
},
|
||||
"act": {
|
||||
"label": "act",
|
||||
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"canvas": {
|
||||
"emoji": "🖼️",
|
||||
"title": "Canvas",
|
||||
"actions": {
|
||||
"present": { "label": "present", "detailKeys": ["target", "node", "nodeId"] },
|
||||
"hide": { "label": "hide", "detailKeys": ["node", "nodeId"] },
|
||||
"navigate": { "label": "navigate", "detailKeys": ["url", "node", "nodeId"] },
|
||||
"eval": { "label": "eval", "detailKeys": ["javaScript", "node", "nodeId"] },
|
||||
"snapshot": { "label": "snapshot", "detailKeys": ["format", "node", "nodeId"] },
|
||||
"a2ui_push": { "label": "A2UI push", "detailKeys": ["jsonlPath", "node", "nodeId"] },
|
||||
"a2ui_reset": { "label": "A2UI reset", "detailKeys": ["node", "nodeId"] }
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"emoji": "📱",
|
||||
"title": "Nodes",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"describe": { "label": "describe", "detailKeys": ["node", "nodeId"] },
|
||||
"pending": { "label": "pending" },
|
||||
"approve": { "label": "approve", "detailKeys": ["requestId"] },
|
||||
"reject": { "label": "reject", "detailKeys": ["requestId"] },
|
||||
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
|
||||
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
|
||||
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
|
||||
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"emoji": "⏰",
|
||||
"title": "Cron",
|
||||
"actions": {
|
||||
"status": { "label": "status" },
|
||||
"list": { "label": "list" },
|
||||
"add": {
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
"actions": {
|
||||
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
|
||||
}
|
||||
},
|
||||
"whatsapp_login": {
|
||||
"emoji": "🟢",
|
||||
"title": "WhatsApp Login",
|
||||
"actions": {
|
||||
"start": { "label": "start" },
|
||||
"wait": { "label": "wait" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"emoji": "💬",
|
||||
"title": "Discord",
|
||||
"actions": {
|
||||
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] },
|
||||
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
|
||||
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
|
||||
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
|
||||
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
|
||||
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
|
||||
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
|
||||
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
|
||||
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
|
||||
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
|
||||
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
|
||||
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
|
||||
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
|
||||
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
|
||||
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
|
||||
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
|
||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
|
||||
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
||||
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
|
||||
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
|
||||
"eventList": { "label": "events", "detailKeys": ["guildId"] },
|
||||
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
|
||||
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
|
||||
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
|
||||
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
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.clawdbot.android
|
||||
|
||||
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,15 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
enum class LocationMode(val rawValue: String) {
|
||||
Off("off"),
|
||||
WhileUsing("whileUsing"),
|
||||
Always("always"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(raw: String?): LocationMode {
|
||||
val normalized = raw?.trim()?.lowercase()
|
||||
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
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.clawdbot.android.ui.RootScreen
|
||||
import com.clawdbot.android.ui.ClawdbotTheme
|
||||
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.sms.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 {
|
||||
ClawdbotTheme {
|
||||
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,168 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.node.CameraCaptureManager
|
||||
import com.clawdbot.android.node.CanvasController
|
||||
import com.clawdbot.android.node.ScreenRecordManager
|
||||
import com.clawdbot.android.node.SmsManager
|
||||
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 sms: SmsManager = runtime.sms
|
||||
|
||||
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 isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
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 setLocationMode(mode: LocationMode) {
|
||||
runtime.setLocationMode(mode)
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
runtime.setLocationPreciseEnabled(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 setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
runtime.setWakeWords(words)
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
runtime.resetWakeWordsDefaults()
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
runtime.setVoiceWakeMode(mode)
|
||||
}
|
||||
|
||||
fun setTalkEnabled(enabled: Boolean) {
|
||||
runtime.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
fun refreshBridgeHello() {
|
||||
runtime.refreshBridgeHello()
|
||||
}
|
||||
|
||||
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.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
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 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 = "Clawdbot 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) "Clawdbot Node · Connected" else "Clawdbot 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() {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Clawdbot node connection status"
|
||||
setShowBadge(false)
|
||||
}
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val launchPending =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(launchPending)
|
||||
.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 (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.clawdbot.android.action.STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(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)
|
||||
1152
apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt
Normal file
1152
apps/android/app/src/main/java/com/clawdbot/android/NodeRuntime.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
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 "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
|
||||
}
|
||||
|
||||
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"
|
||||
Manifest.permission.SEND_SMS -> "SMS"
|
||||
else -> permission
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
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("Clawdbot 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,218 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
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,
|
||||
"clawdbot.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 _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
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 _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
prefs.edit { putInt("bridge.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.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()) }
|
||||
}
|
||||
|
||||
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) }
|
||||
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) }
|
||||
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) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
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.clawdbot.android
|
||||
|
||||
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.clawdbot.android
|
||||
|
||||
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,35 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
object BonjourEscapes {
|
||||
fun decode(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
|
||||
val bytes = mutableListOf<Byte>()
|
||||
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..255) {
|
||||
bytes.add(value.toByte())
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val codePoint = Character.codePointAt(input, i)
|
||||
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
|
||||
for (b in charBytes) {
|
||||
bytes.add(b)
|
||||
}
|
||||
i += Character.charCount(codePoint)
|
||||
}
|
||||
|
||||
return String(bytes.toByteArray(), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package com.clawdbot.android.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.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
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
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 = "_clawdbot-bridge._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/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 lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val bridgePort = txtInt(resolved, "bridgePort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
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? {
|
||||
val bytes = info.attributes[key] ?: return null
|
||||
return try {
|
||||
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
||||
return txt(info, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
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 lanHost = txtValue(txt, "lanHost")
|
||||
val tailnetDns = txtValue(txt, "tailnetDns")
|
||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||
val id = stableId(instanceName, domain)
|
||||
next[id] =
|
||||
BridgeEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
bridgePort = bridgePort,
|
||||
canvasPort = canvasPort,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
DnsResolver.FLAG_EMPTY,
|
||||
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 txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
||||
return txtValue(records, key)?.toIntOrNull()
|
||||
}
|
||||
|
||||
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,23 @@
|
||||
package com.clawdbot.android.bridge
|
||||
|
||||
data class BridgeEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val lanHost: String? = null,
|
||||
val tailnetDns: String? = null,
|
||||
val gatewayPort: Int? = null,
|
||||
val bridgePort: Int? = null,
|
||||
val canvasPort: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||
BridgeEndpoint(
|
||||
stableId = "manual|$host|$port",
|
||||
name = "$host:$port",
|
||||
host = host,
|
||||
port = port,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.clawdbot.android.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
|
||||
try {
|
||||
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())
|
||||
|
||||
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")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||
PairResult(ok = false, token = null, error = message)
|
||||
} 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,370 @@
|
||||
package com.clawdbot.android.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 com.clawdbot.android.BuildConfig
|
||||
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.URI
|
||||
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>>()
|
||||
@Volatile private var canvasHostUrl: String? = null
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateHello(hello: Hello) {
|
||||
val target = desired ?: return
|
||||
desired = target.first to hello
|
||||
val conn = currentConnection ?: return
|
||||
conn.sendJson(buildHelloJson(hello))
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
job = null
|
||||
canvasHostUrl = null
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||
|
||||
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(buildHelloJson(hello))
|
||||
|
||||
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"
|
||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||
runCatching {
|
||||
android.util.Log.d(
|
||||
"ClawdbotBridge",
|
||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||
)
|
||||
}
|
||||
}
|
||||
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 buildHelloJson(hello: Hello): JsonObject =
|
||||
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))) }
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
||||
val host = parsed?.host?.trim().orEmpty()
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
val fallbackHost =
|
||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
val host = raw?.trim()?.lowercase().orEmpty()
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
if (host == "::1") return true
|
||||
if (host == "0.0.0.0" || host == "::") return true
|
||||
return host.startsWith("127.")
|
||||
}
|
||||
}
|
||||
|
||||
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,512 @@
|
||||
package com.clawdbot.android.chat
|
||||
|
||||
import com.clawdbot.android.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") {
|
||||
val args = data?.get("args").asObjectOrNull()
|
||||
pendingToolCallsById[toolCallId] =
|
||||
ChatPendingToolCall(
|
||||
toolCallId = toolCallId,
|
||||
name = name,
|
||||
args = args,
|
||||
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()
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
||||
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,44 @@
|
||||
package com.clawdbot.android.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 args: kotlinx.serialization.json.JsonObject? = null,
|
||||
val startedAtMs: Long,
|
||||
val isError: Boolean? = null,
|
||||
)
|
||||
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
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,281 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
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 androidx.core.graphics.scale
|
||||
import com.clawdbot.android.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.math.roundToInt
|
||||
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)
|
||||
decoded.scale(maxWidth, h)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
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("clawdbot-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("clawdbot-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,264 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.WebView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import com.clawdbot.android.BuildConfig
|
||||
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
|
||||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: 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()
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun navigate(url: String) {
|
||||
val trimmed = url.trim()
|
||||
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
fun setDebugStatusEnabled(enabled: Boolean) {
|
||||
debugStatusEnabled = enabled
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun setDebugStatus(title: String?, subtitle: String?) {
|
||||
debugStatusTitle = title
|
||||
debugStatusSubtitle = subtitle
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
}
|
||||
|
||||
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) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
|
||||
}
|
||||
wv.loadUrl(scaffoldAssetUrl)
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("ClawdbotCanvas", "load url: $currentUrl")
|
||||
}
|
||||
wv.loadUrl(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDebugStatus() {
|
||||
val enabled = debugStatusEnabled
|
||||
val title = debugStatusTitle
|
||||
val subtitle = debugStatusSubtitle
|
||||
withWebViewOnMain { wv ->
|
||||
val titleJs = title?.let { JSONObject.quote(it) } ?: "null"
|
||||
val subtitleJs = subtitle?.let { JSONObject.quote(it) } ?: "null"
|
||||
val js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__clawdbot;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
||||
}
|
||||
if (!${if (enabled) "true" else "false"}) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus($titleJs, $subtitleJs);
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
bmp.scale(maxWidth, h)
|
||||
} 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)
|
||||
bmp.scale(maxWidth, h)
|
||||
} 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 = 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,61 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class JpegSizeLimiterResult(
|
||||
val bytes: ByteArray,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val quality: Int,
|
||||
)
|
||||
|
||||
internal object JpegSizeLimiter {
|
||||
fun compressToLimit(
|
||||
initialWidth: Int,
|
||||
initialHeight: Int,
|
||||
startQuality: Int,
|
||||
maxBytes: Int,
|
||||
minQuality: Int = 20,
|
||||
minSize: Int = 256,
|
||||
scaleStep: Double = 0.85,
|
||||
maxScaleAttempts: Int = 6,
|
||||
maxQualityAttempts: Int = 6,
|
||||
encode: (width: Int, height: Int, quality: Int) -> ByteArray,
|
||||
): JpegSizeLimiterResult {
|
||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
||||
|
||||
var width = initialWidth
|
||||
var height = initialHeight
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
|
||||
if (best.bytes.size <= maxBytes) return best
|
||||
|
||||
repeat(maxScaleAttempts) {
|
||||
var quality = clampedStartQuality
|
||||
repeat(maxQualityAttempts) {
|
||||
val bytes = encode(width, height, quality)
|
||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
if (bytes.size <= maxBytes) return best
|
||||
if (quality <= minQuality) return@repeat
|
||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
||||
}
|
||||
|
||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
||||
val nextScale = max(scaleStep, minScale)
|
||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
||||
val nextHeight = max(minSize, (height * nextScale).roundToInt())
|
||||
if (nextWidth == width && nextHeight == height) return@repeat
|
||||
width = min(nextWidth, width)
|
||||
height = min(nextHeight, height)
|
||||
}
|
||||
|
||||
if (best.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.CancellationSignal
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
class LocationCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
|
||||
suspend fun getLocation(
|
||||
desiredProviders: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
timeoutMs: Long,
|
||||
isPrecise: Boolean,
|
||||
): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
|
||||
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
) {
|
||||
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
|
||||
}
|
||||
|
||||
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
|
||||
val location =
|
||||
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
|
||||
|
||||
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
|
||||
val source = location.provider
|
||||
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
|
||||
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
|
||||
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
|
||||
Payload(
|
||||
buildString {
|
||||
append("{\"lat\":")
|
||||
append(location.latitude)
|
||||
append(",\"lon\":")
|
||||
append(location.longitude)
|
||||
append(",\"accuracyMeters\":")
|
||||
append(location.accuracy.toDouble())
|
||||
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
|
||||
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
|
||||
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
|
||||
append(",\"timestamp\":\"").append(timestamp).append('"')
|
||||
append(",\"isPrecise\":").append(isPrecise)
|
||||
append(",\"source\":\"").append(source).append('"')
|
||||
append('}')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun bestLastKnown(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
): Location? {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidates =
|
||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
val freshest = candidates.maxByOrNull { it.time } ?: return null
|
||||
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
|
||||
return freshest
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun requestCurrent(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
timeoutMs: Long,
|
||||
): Location {
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
return withTimeout(timeoutMs.coerceAtLeast(1)) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val signal = CancellationSignal()
|
||||
cont.invokeOnCancellation { signal.cancel() }
|
||||
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
|
||||
if (location != null) {
|
||||
cont.resume(location)
|
||||
} else {
|
||||
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.clawdbot.android.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.clawdbot.android.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.clawdbot.android.PermissionRequester? = null
|
||||
|
||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||
screenCaptureRequester = requester
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: com.clawdbot.android.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("clawdbot-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(
|
||||
"clawdbot-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,230 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.telephony.SmsManager as AndroidSmsManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.encodeToString
|
||||
import com.clawdbot.android.PermissionRequester
|
||||
|
||||
/**
|
||||
* Sends SMS messages via the Android SMS API.
|
||||
* Requires SEND_SMS permission to be granted.
|
||||
*/
|
||||
class SmsManager(private val context: Context) {
|
||||
|
||||
private val json = JsonConfig
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
data class SendResult(
|
||||
val ok: Boolean,
|
||||
val to: String,
|
||||
val message: String?,
|
||||
val error: String? = null,
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
internal data class ParsedParams(
|
||||
val to: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
internal sealed class ParseResult {
|
||||
data class Ok(val params: ParsedParams) : ParseResult()
|
||||
data class Error(
|
||||
val error: String,
|
||||
val to: String = "",
|
||||
val message: String? = null,
|
||||
) : ParseResult()
|
||||
}
|
||||
|
||||
internal data class SendPlan(
|
||||
val parts: List<String>,
|
||||
val useMultipart: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
||||
val params = paramsJson?.trim().orEmpty()
|
||||
if (params.isEmpty()) {
|
||||
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
|
||||
}
|
||||
|
||||
val obj = try {
|
||||
json.parseToJsonElement(params).jsonObject
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
if (obj == null) {
|
||||
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
|
||||
}
|
||||
|
||||
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
|
||||
|
||||
if (to.isEmpty()) {
|
||||
return ParseResult.Error(
|
||||
error = "INVALID_REQUEST: 'to' phone number required",
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
if (message.isEmpty()) {
|
||||
return ParseResult.Error(
|
||||
error = "INVALID_REQUEST: 'message' text required",
|
||||
to = to,
|
||||
)
|
||||
}
|
||||
|
||||
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
||||
}
|
||||
|
||||
internal fun buildSendPlan(
|
||||
message: String,
|
||||
divider: (String) -> List<String>,
|
||||
): SendPlan {
|
||||
val parts = divider(message).ifEmpty { listOf(message) }
|
||||
return SendPlan(parts = parts, useMultipart = parts.size > 1)
|
||||
}
|
||||
|
||||
internal fun buildPayloadJson(
|
||||
json: Json = JsonConfig,
|
||||
ok: Boolean,
|
||||
to: String,
|
||||
error: String?,
|
||||
): String {
|
||||
val payload =
|
||||
mutableMapOf<String, JsonElement>(
|
||||
"ok" to JsonPrimitive(ok),
|
||||
"to" to JsonPrimitive(to),
|
||||
)
|
||||
if (!ok) {
|
||||
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
||||
}
|
||||
}
|
||||
|
||||
fun hasSmsPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.SEND_SMS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun canSendSms(): Boolean {
|
||||
return hasSmsPermission() && hasTelephonyFeature()
|
||||
}
|
||||
|
||||
fun hasTelephonyFeature(): Boolean {
|
||||
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an SMS message.
|
||||
*
|
||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||
* @return SendResult indicating success or failure
|
||||
*/
|
||||
suspend fun send(paramsJson: String?): SendResult {
|
||||
if (!hasTelephonyFeature()) {
|
||||
return errorResult(
|
||||
error = "SMS_UNAVAILABLE: telephony not available",
|
||||
)
|
||||
}
|
||||
|
||||
if (!ensureSmsPermission()) {
|
||||
return errorResult(
|
||||
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
|
||||
)
|
||||
}
|
||||
|
||||
val parseResult = parseParams(paramsJson, json)
|
||||
if (parseResult is ParseResult.Error) {
|
||||
return errorResult(
|
||||
error = parseResult.error,
|
||||
to = parseResult.to,
|
||||
message = parseResult.message,
|
||||
)
|
||||
}
|
||||
val params = (parseResult as ParseResult.Ok).params
|
||||
|
||||
return try {
|
||||
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
|
||||
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
|
||||
|
||||
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
|
||||
if (plan.useMultipart) {
|
||||
smsManager.sendMultipartTextMessage(
|
||||
params.to, // destination
|
||||
null, // service center (null = default)
|
||||
ArrayList(plan.parts), // message parts
|
||||
null, // sent intents
|
||||
null, // delivery intents
|
||||
)
|
||||
} else {
|
||||
smsManager.sendTextMessage(
|
||||
params.to, // destination
|
||||
null, // service center (null = default)
|
||||
params.message,// message
|
||||
null, // sent intent
|
||||
null, // delivery intent
|
||||
)
|
||||
}
|
||||
|
||||
okResult(to = params.to, message = params.message)
|
||||
} catch (e: SecurityException) {
|
||||
errorResult(
|
||||
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
||||
to = params.to,
|
||||
message = params.message,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
errorResult(
|
||||
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
|
||||
to = params.to,
|
||||
message = params.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureSmsPermission(): Boolean {
|
||||
if (hasSmsPermission()) return true
|
||||
val requester = permissionRequester ?: return false
|
||||
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
|
||||
return results[Manifest.permission.SEND_SMS] == true
|
||||
}
|
||||
|
||||
private fun okResult(to: String, message: String): SendResult {
|
||||
return SendResult(
|
||||
ok = true,
|
||||
to = to,
|
||||
message = message,
|
||||
error = null,
|
||||
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
|
||||
return SendResult(
|
||||
ok = false,
|
||||
to = to,
|
||||
message = message,
|
||||
error = error,
|
||||
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.clawdbot.android.protocol
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object ClawdbotCanvasA2UIAction {
|
||||
fun extractActionName(userAction: JsonObject): String? {
|
||||
val name =
|
||||
(userAction["name"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (name.isNotEmpty()) return name
|
||||
val action =
|
||||
(userAction["action"] as? JsonPrimitive)
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return action.ifEmpty { null }
|
||||
}
|
||||
|
||||
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('clawdbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.clawdbot.android.protocol
|
||||
|
||||
enum class ClawdbotCapability(val rawValue: String) {
|
||||
Canvas("canvas"),
|
||||
Camera("camera"),
|
||||
Screen("screen"),
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
}
|
||||
|
||||
enum class ClawdbotCanvasCommand(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 ClawdbotCanvasA2UICommand(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 ClawdbotCameraCommand(val rawValue: String) {
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "camera."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotScreenCommand(val rawValue: String) {
|
||||
Record("screen.record"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "screen."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "sms."
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClawdbotLocationCommand(val rawValue: String) {
|
||||
Get("location.get"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.clawdbot.android.tools
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplayActionSpec(
|
||||
val label: String? = null,
|
||||
val detailKeys: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplaySpec(
|
||||
val emoji: String? = null,
|
||||
val title: String? = null,
|
||||
val label: String? = null,
|
||||
val detailKeys: List<String>? = null,
|
||||
val actions: Map<String, ToolDisplayActionSpec>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ToolDisplayConfig(
|
||||
val version: Int? = null,
|
||||
val fallback: ToolDisplaySpec? = null,
|
||||
val tools: Map<String, ToolDisplaySpec>? = null,
|
||||
)
|
||||
|
||||
data class ToolDisplaySummary(
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
val title: String,
|
||||
val label: String,
|
||||
val verb: String?,
|
||||
val detail: String?,
|
||||
) {
|
||||
val detailLine: String?
|
||||
get() {
|
||||
val parts = mutableListOf<String>()
|
||||
if (!verb.isNullOrBlank()) parts.add(verb)
|
||||
if (!detail.isNullOrBlank()) parts.add(detail)
|
||||
return if (parts.isEmpty()) null else parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
val summaryLine: String
|
||||
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
|
||||
}
|
||||
|
||||
object ToolDisplayRegistry {
|
||||
private const val CONFIG_ASSET = "tool-display.json"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@Volatile private var cachedConfig: ToolDisplayConfig? = null
|
||||
|
||||
fun resolve(
|
||||
context: Context,
|
||||
name: String?,
|
||||
args: JsonObject?,
|
||||
meta: String? = null,
|
||||
): ToolDisplaySummary {
|
||||
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
|
||||
val key = trimmedName.lowercase()
|
||||
val config = loadConfig(context)
|
||||
val spec = config.tools?.get(key)
|
||||
val fallback = config.fallback
|
||||
|
||||
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
|
||||
val title = spec?.title ?: titleFromName(trimmedName)
|
||||
val label = spec?.label ?: trimmedName
|
||||
|
||||
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
|
||||
val action = actionRaw?.takeIf { it.isNotEmpty() }
|
||||
val actionSpec = action?.let { spec?.actions?.get(it) }
|
||||
val verb = normalizeVerb(actionSpec?.label ?: action)
|
||||
|
||||
var detail: String? = null
|
||||
if (key == "read") {
|
||||
detail = readDetail(args)
|
||||
} else if (key == "write" || key == "edit" || key == "attach") {
|
||||
detail = pathDetail(args)
|
||||
}
|
||||
|
||||
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
|
||||
if (detail == null) {
|
||||
detail = firstValue(args, detailKeys)
|
||||
}
|
||||
|
||||
if (detail == null) {
|
||||
detail = meta
|
||||
}
|
||||
|
||||
if (detail != null) {
|
||||
detail = shortenHomeInString(detail)
|
||||
}
|
||||
|
||||
return ToolDisplaySummary(
|
||||
name = trimmedName,
|
||||
emoji = emoji,
|
||||
title = title,
|
||||
label = label,
|
||||
verb = verb,
|
||||
detail = detail,
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadConfig(context: Context): ToolDisplayConfig {
|
||||
val existing = cachedConfig
|
||||
if (existing != null) return existing
|
||||
return try {
|
||||
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
|
||||
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
|
||||
cachedConfig = decoded
|
||||
decoded
|
||||
} catch (_: Throwable) {
|
||||
val fallback = ToolDisplayConfig()
|
||||
cachedConfig = fallback
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun titleFromName(name: String): String {
|
||||
val cleaned = name.replace("_", " ").trim()
|
||||
if (cleaned.isEmpty()) return "Tool"
|
||||
return cleaned
|
||||
.split(Regex("\\s+"))
|
||||
.joinToString(" ") { part ->
|
||||
val upper = part.uppercase()
|
||||
if (part.length <= 2 && part == upper) part
|
||||
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeVerb(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return trimmed.replace("_", " ")
|
||||
}
|
||||
|
||||
private fun readDetail(args: JsonObject?): String? {
|
||||
val path = args?.get("path")?.asStringOrNull() ?: return null
|
||||
val offset = args["offset"].asNumberOrNull()
|
||||
val limit = args["limit"].asNumberOrNull()
|
||||
return if (offset != null && limit != null) {
|
||||
val end = offset + limit
|
||||
"${path}:${offset.toInt()}-${end.toInt()}"
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
private fun pathDetail(args: JsonObject?): String? {
|
||||
return args?.get("path")?.asStringOrNull()
|
||||
}
|
||||
|
||||
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
|
||||
for (key in keys) {
|
||||
val value = valueForPath(args, key)
|
||||
val rendered = renderValue(value)
|
||||
if (!rendered.isNullOrBlank()) return rendered
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
|
||||
var current: JsonElement? = args
|
||||
for (segment in path.split(".")) {
|
||||
if (segment.isBlank()) return null
|
||||
val obj = current as? JsonObject ?: return null
|
||||
current = obj[segment]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun renderValue(value: JsonElement?): String? {
|
||||
if (value == null) return null
|
||||
if (value is JsonPrimitive) {
|
||||
if (value.isString) {
|
||||
val trimmed = value.contentOrNull?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
if (firstLine.isEmpty()) return null
|
||||
return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine
|
||||
}
|
||||
val raw = value.contentOrNull?.trim().orEmpty()
|
||||
raw.toBooleanStrictOrNull()?.let { return it.toString() }
|
||||
raw.toLongOrNull()?.let { return it.toString() }
|
||||
raw.toDoubleOrNull()?.let { return it.toString() }
|
||||
}
|
||||
if (value is JsonArray) {
|
||||
val items = value.mapNotNull { renderValue(it) }
|
||||
if (items.isEmpty()) return null
|
||||
val preview = items.take(3).joinToString(", ")
|
||||
return if (items.size > 3) "${preview}…" else preview
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun shortenHomeInString(value: String): String {
|
||||
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
|
||||
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
|
||||
if (home.isNullOrEmpty()) return value
|
||||
return value.replace(home, "~")
|
||||
.replace(Regex("/Users/[^/]+"), "~")
|
||||
.replace(Regex("/home/[^/]+"), "~")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asNumberOrNull(): Double? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
val raw = primitive.contentOrNull ?: return null
|
||||
return raw.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.clawdbot.android.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun CameraFlashOverlay(
|
||||
token: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CameraFlash(token = token)
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.clawdbot.android.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.ui.chat.ChatSheetContent
|
||||
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
ChatSheetContent(viewModel = viewModel)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.clawdbot.android.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 ClawdbotTheme(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,444 @@
|
||||
package com.clawdbot.android.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.ConsoleMessage
|
||||
import android.webkit.WebChromeClient
|
||||
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.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.webkit.WebSettingsCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
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.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.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.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.ScreenShare
|
||||
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.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
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.clawdbot.android.CameraHudKind
|
||||
import com.clawdbot.android.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 screenRecordActive by viewModel.screenRecordActive.collectAsState()
|
||||
val isForeground by viewModel.isForeground.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
||||
val talkStatusText by viewModel.talkStatusText.collectAsState()
|
||||
val talkIsListening by viewModel.talkIsListening.collectAsState()
|
||||
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
|
||||
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
||||
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) viewModel.setTalkEnabled(true)
|
||||
}
|
||||
val activity =
|
||||
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if (!isForeground) {
|
||||
return@remember StatusActivity(
|
||||
title = "Foreground required",
|
||||
icon = Icons.Default.Report,
|
||||
contentDescription = "Foreground required",
|
||||
)
|
||||
}
|
||||
|
||||
val lowerStatus = statusText.lowercase()
|
||||
if (lowerStatus.contains("repair")) {
|
||||
return@remember StatusActivity(
|
||||
title = "Repairing…",
|
||||
icon = Icons.Default.Refresh,
|
||||
contentDescription = "Repairing",
|
||||
)
|
||||
}
|
||||
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
|
||||
return@remember StatusActivity(
|
||||
title = "Approval pending",
|
||||
icon = Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Approval pending",
|
||||
)
|
||||
}
|
||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
||||
|
||||
if (screenRecordActive) {
|
||||
return@remember StatusActivity(
|
||||
title = "Recording screen…",
|
||||
icon = Icons.Default.ScreenShare,
|
||||
contentDescription = "Recording screen",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
}
|
||||
|
||||
cameraHud?.let { hud ->
|
||||
return@remember when (hud.kind) {
|
||||
CameraHudKind.Photo ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.PhotoCamera,
|
||||
contentDescription = "Taking photo",
|
||||
)
|
||||
CameraHudKind.Recording ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.FiberManualRecord,
|
||||
contentDescription = "Recording",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
CameraHudKind.Success ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.CheckCircle,
|
||||
contentDescription = "Capture finished",
|
||||
)
|
||||
CameraHudKind.Error ->
|
||||
StatusActivity(
|
||||
title = hud.message,
|
||||
icon = Icons.Default.Error,
|
||||
contentDescription = "Capture failed",
|
||||
tint = androidx.compose.ui.graphics.Color.Red,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
|
||||
return@remember StatusActivity(
|
||||
title = "Mic permission",
|
||||
icon = Icons.Default.Error,
|
||||
contentDescription = "Mic permission required",
|
||||
)
|
||||
}
|
||||
if (voiceWakeStatusText == "Paused") {
|
||||
val suffix = if (!isForeground) " (background)" else ""
|
||||
return@remember StatusActivity(
|
||||
title = "Voice Wake paused$suffix",
|
||||
icon = Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Voice Wake paused",
|
||||
)
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
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 flash must be in a Popup to render above the WebView.
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
CameraFlashOverlay(token = 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,
|
||||
activity = activity,
|
||||
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") },
|
||||
)
|
||||
|
||||
// Talk mode gets a dedicated side bubble instead of burying it in settings.
|
||||
val baseOverlay = overlayContainerColor()
|
||||
val talkContainer =
|
||||
lerp(
|
||||
baseOverlay,
|
||||
seamColor.copy(alpha = baseOverlay.alpha),
|
||||
if (talkEnabled) 0.35f else 0.22f,
|
||||
)
|
||||
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
|
||||
OverlayIconButton(
|
||||
onClick = {
|
||||
val next = !talkEnabled
|
||||
if (next) {
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
viewModel.setTalkEnabled(true)
|
||||
} else {
|
||||
viewModel.setTalkEnabled(false)
|
||||
}
|
||||
},
|
||||
containerColor = talkContainer,
|
||||
contentColor = talkContent,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.RecordVoiceOver,
|
||||
contentDescription = "Talk Mode",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
OverlayIconButton(
|
||||
onClick = { sheet = Sheet.Settings },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (talkEnabled) {
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
TalkOrbOverlay(
|
||||
seamColor = seamColor,
|
||||
statusText = talkStatusText,
|
||||
isListening = talkIsListening,
|
||||
isSpeaking = talkIsSpeaking,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
containerColor: ComposeColor? = null,
|
||||
contentColor: ComposeColor? = null,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = containerColor ?: overlayContainerColor(),
|
||||
contentColor = 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
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
}
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
||||
}
|
||||
isScrollContainer = true
|
||||
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
isVerticalScrollBarEnabled = true
|
||||
isHorizontalScrollBarEnabled = true
|
||||
webViewClient =
|
||||
object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError,
|
||||
) {
|
||||
if (!isDebuggable) return
|
||||
if (!request.isForMainFrame) return
|
||||
Log.e("ClawdbotWebView", "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(
|
||||
"ClawdbotWebView",
|
||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
if (isDebuggable) {
|
||||
Log.d("ClawdbotWebView", "onPageFinished: $url")
|
||||
}
|
||||
viewModel.canvas.onPageFinished()
|
||||
}
|
||||
|
||||
override fun onRenderProcessGone(
|
||||
view: WebView,
|
||||
detail: android.webkit.RenderProcessGoneDetail,
|
||||
): Boolean {
|
||||
if (isDebuggable) {
|
||||
Log.e(
|
||||
"ClawdbotWebView",
|
||||
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
webChromeClient =
|
||||
object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
if (!isDebuggable) return false
|
||||
val msg = consoleMessage ?: return false
|
||||
Log.d(
|
||||
"ClawdbotWebView",
|
||||
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Use default layer/background; avoid forcing a black fill over WebView content.
|
||||
|
||||
val a2uiBridge =
|
||||
CanvasA2UIActionBridge { payload ->
|
||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||
}
|
||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
||||
addJavascriptInterface(
|
||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||
CanvasA2UIActionLegacyBridge.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 = "clawdbotCanvasA2UIAction"
|
||||
}
|
||||
}
|
||||
|
||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
||||
@JavascriptInterface
|
||||
fun canvasAction(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun postMessage(payload: String?) {
|
||||
bridge.postMessage(payload)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val interfaceName: String = "Android"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package com.clawdbot.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.runtime.setValue
|
||||
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.clawdbot.android.BuildConfig
|
||||
import com.clawdbot.android.LocationMode
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.NodeForegroundService
|
||||
import com.clawdbot.android.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 locationMode by viewModel.locationMode.collectAsState()
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.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) }
|
||||
val deviceModel =
|
||||
remember {
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { "Android" }
|
||||
}
|
||||
val appVersion =
|
||||
remember {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||
viewModel.setCameraEnabled(cameraOk)
|
||||
}
|
||||
|
||||
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
|
||||
var pendingPreciseToggle by remember { mutableStateOf(false) }
|
||||
|
||||
val locationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
|
||||
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
val granted = fineOk || coarseOk
|
||||
val requestedMode = pendingLocationMode
|
||||
pendingLocationMode = null
|
||||
|
||||
if (pendingPreciseToggle) {
|
||||
pendingPreciseToggle = false
|
||||
viewModel.setLocationPreciseEnabled(fineOk)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
|
||||
if (!granted) {
|
||||
viewModel.setLocationMode(LocationMode.Off)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
|
||||
if (requestedMode != null) {
|
||||
viewModel.setLocationMode(requestedMode)
|
||||
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!backgroundOk) {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||
// Status text is handled by NodeRuntime.
|
||||
}
|
||||
|
||||
val smsPermissionAvailable =
|
||||
remember {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
var smsPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
val smsPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
smsPermissionGranted = granted
|
||||
viewModel.refreshBridgeHello()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
fun requestLocationPermissions(targetMode: LocationMode) {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk || coarseOk) {
|
||||
viewModel.setLocationMode(targetMode)
|
||||
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!backgroundOk) {
|
||||
openAppSettings(context)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingLocationMode = targetMode
|
||||
locationPermissionLauncher.launch(
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPreciseLocationChecked(checked: Boolean) {
|
||||
if (!checked) {
|
||||
viewModel.setLocationPreciseEnabled(false)
|
||||
return
|
||||
}
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk) {
|
||||
viewModel.setLocationPreciseEnabled(true)
|
||||
} else {
|
||||
pendingPreciseToggle = true
|
||||
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
||||
}
|
||||
}
|
||||
|
||||
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 → Messaging → Location → 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 { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
item { Text("Version: $appVersion", 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 ->
|
||||
val detailLines =
|
||||
buildList {
|
||||
add("IP: ${bridge.host}:${bridge.port}")
|
||||
bridge.lanHost?.let { add("LAN: $it") }
|
||||
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
||||
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
||||
val gw = bridge.gatewayPort?.toString() ?: "—"
|
||||
val br = (bridge.bridgePort ?: bridge.port).toString()
|
||||
val canvas = bridge.canvasPort?.toString() ?: "—"
|
||||
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(bridge.name) },
|
||||
supportingContent = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
detailLines.forEach { line ->
|
||||
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
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 Clawdbot 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.clawdbot.android.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() }
|
||||
|
||||
// Messaging
|
||||
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
val buttonLabel =
|
||||
when {
|
||||
!smsPermissionAvailable -> "Unavailable"
|
||||
smsPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text("SMS Permission") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (smsPermissionAvailable) {
|
||||
"Allow the bridge to send SMS from this device."
|
||||
} else {
|
||||
"SMS requires a device with telephony hardware."
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (!smsPermissionAvailable) return@Button
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
}
|
||||
},
|
||||
enabled = smsPermissionAvailable,
|
||||
) {
|
||||
Text(buttonLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Location
|
||||
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Off") },
|
||||
supportingContent = { Text("Disable location sharing.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.Off,
|
||||
onClick = { viewModel.setLocationMode(LocationMode.Off) },
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("While Using") },
|
||||
supportingContent = { Text("Only while Clawdbot is open.") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.WhileUsing,
|
||||
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
|
||||
)
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Always") },
|
||||
supportingContent = { Text("Allow background location (requires system permission).") },
|
||||
trailingContent = {
|
||||
RadioButton(
|
||||
selected = locationMode == LocationMode.Always,
|
||||
onClick = { requestLocationPermissions(LocationMode.Always) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Precise Location") },
|
||||
supportingContent = { Text("Use precise GPS when available.") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = locationPreciseEnabled,
|
||||
onCheckedChange = ::setPreciseLocationChecked,
|
||||
enabled = locationMode != LocationMode.Off,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Always may require Android Settings to allow background location.",
|
||||
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 Clawdbot is open.") },
|
||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
// Debug
|
||||
item { Text("Debug", style = MaterialTheme.typography.titleSmall) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text("Debug Canvas Status") },
|
||||
supportingContent = { Text("Show status text in the canvas when debug is enabled.") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = canvasDebugStatusEnabled,
|
||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null),
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.clawdbot.android.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,
|
||||
activity: StatusActivity? = null,
|
||||
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,
|
||||
)
|
||||
|
||||
if (activity != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = activity.icon,
|
||||
contentDescription = activity.contentDescription,
|
||||
tint = activity.tint ?: overlayIconColor(),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = activity.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusActivity(
|
||||
val title: String,
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
val contentDescription: String,
|
||||
val tint: Color? = null,
|
||||
)
|
||||
|
||||
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,134 @@
|
||||
package com.clawdbot.android.ui
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TalkOrbOverlay(
|
||||
seamColor: Color,
|
||||
statusText: String,
|
||||
isListening: Boolean,
|
||||
isSpeaking: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "talk-orb")
|
||||
val t by
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "pulse",
|
||||
)
|
||||
|
||||
val trimmed = statusText.trim()
|
||||
val showStatus = trimmed.isNotEmpty() && trimmed != "Off"
|
||||
val phase =
|
||||
when {
|
||||
isSpeaking -> "Speaking"
|
||||
isListening -> "Listening"
|
||||
else -> "Thinking"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Canvas(modifier = Modifier.size(360.dp)) {
|
||||
val center = this.center
|
||||
val baseRadius = size.minDimension * 0.30f
|
||||
|
||||
val ring1 = 1.05f + (t * 0.25f)
|
||||
val ring2 = 1.20f + (t * 0.55f)
|
||||
val ringAlpha1 = (1f - t) * 0.34f
|
||||
val ringAlpha2 = (1f - t) * 0.22f
|
||||
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = ringAlpha1),
|
||||
radius = baseRadius * ring1,
|
||||
center = center,
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
)
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = ringAlpha2),
|
||||
radius = baseRadius * ring2,
|
||||
center = center,
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
brush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
seamColor.copy(alpha = 0.92f),
|
||||
seamColor.copy(alpha = 0.40f),
|
||||
Color.Black.copy(alpha = 0.56f),
|
||||
),
|
||||
center = center,
|
||||
radius = baseRadius * 1.35f,
|
||||
),
|
||||
radius = baseRadius,
|
||||
center = center,
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = seamColor.copy(alpha = 0.34f),
|
||||
radius = baseRadius,
|
||||
center = center,
|
||||
style = Stroke(width = 1.dp.toPx()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.40f),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text(
|
||||
text = trimmed,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = phase,
|
||||
color = Color.White.copy(alpha = 0.80f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.clawdbot.android.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.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
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
@Composable
|
||||
fun ChatComposer(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
healthOk: Boolean,
|
||||
thinkingLevel: String,
|
||||
pendingRunCount: Int,
|
||||
errorText: String?,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (id: String) -> Unit,
|
||||
onSetThinkingLevel: (level: String) -> Unit,
|
||||
onSelectSession: (sessionKey: String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: (text: String) -> Unit,
|
||||
) {
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
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 = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.displayName ?: entry.key) },
|
||||
onClick = {
|
||||
onSelectSession(entry.key)
|
||||
showSessionMenu = false
|
||||
},
|
||||
trailingIcon = {
|
||||
if (entry.key == sessionKey) {
|
||||
Text("✓")
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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(sessionLabel = currentSessionLabel, 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(sessionLabel: 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(sessionLabel, 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,215 @@
|
||||
package com.clawdbot.android.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.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.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, textColor: Color) {
|
||||
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 = textColor,
|
||||
)
|
||||
}
|
||||
is ChatMarkdownBlock.Code -> {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
ChatCodeBlock(code = b.code, language = b.language)
|
||||
}
|
||||
}
|
||||
is ChatMarkdownBlock.InlineImage -> {
|
||||
InlineBase64Image(base64 = b.base64, mimeType = b.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.clawdbot.android.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.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.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,252 @@
|
||||
package com.clawdbot.android.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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatMessageContent
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.tools.ToolDisplayRegistry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@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),
|
||||
) {
|
||||
val textColor = textColorOverBubble(isUser)
|
||||
ChatMessageBody(content = message.content, textColor = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
for (part in content) {
|
||||
when (part.type) {
|
||||
"text" -> {
|
||||
val text = part.text ?: continue
|
||||
ChatMarkdown(text = text, textColor = textColor)
|
||||
}
|
||||
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>) {
|
||||
val context = LocalContext.current
|
||||
val displays =
|
||||
remember(toolCalls, context) {
|
||||
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
|
||||
}
|
||||
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("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
for (display in displays.take(6)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
"${display.emoji} ${display.label}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
display.detailLine?.let { detail ->
|
||||
Text(
|
||||
detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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, textColor = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 textColorOverBubble(isUser: Boolean): Color {
|
||||
return if (isUser) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
}
|
||||
|
||||
@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,92 @@
|
||||
package com.clawdbot.android.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.clawdbot.android.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.displayName ?: 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,145 @@
|
||||
package com.clawdbot.android.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.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.clawdbot.android.MainViewModel
|
||||
import com.clawdbot.android.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()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat("main")
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
|
||||
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,
|
||||
sessions = sessions,
|
||||
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) },
|
||||
onSelectSession = { key -> viewModel.switchChatSession(key) },
|
||||
onRefresh = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
},
|
||||
onAbort = { viewModel.abortChat() },
|
||||
onSend = { text ->
|
||||
val outgoing =
|
||||
attachments.map { att ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = att.mimeType,
|
||||
fileName = att.fileName,
|
||||
base64 = att.base64,
|
||||
)
|
||||
}
|
||||
viewModel.sendChat(message = text, thinking = thinkingLevel, attachments = outgoing)
|
||||
attachments.clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,46 @@
|
||||
package com.clawdbot.android.ui.chat
|
||||
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
|
||||
private const val MAIN_SESSION_KEY = "main"
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
): List<ChatSessionEntry> {
|
||||
val current = currentSessionKey.trim()
|
||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
val recent = mutableListOf<ChatSessionEntry>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (entry in sorted) {
|
||||
if (!seen.add(entry.key)) continue
|
||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||
recent.add(entry)
|
||||
}
|
||||
|
||||
val result = mutableListOf<ChatSessionEntry>()
|
||||
val included = mutableSetOf<String>()
|
||||
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||
if (mainEntry != null) {
|
||||
result.add(mainEntry)
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
} else if (current == MAIN_SESSION_KEY) {
|
||||
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||
included.add(MAIN_SESSION_KEY)
|
||||
}
|
||||
|
||||
for (entry in recent) {
|
||||
if (included.add(entry.key)) {
|
||||
result.add(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.isNotEmpty() && !included.contains(current)) {
|
||||
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user