Compare commits
619 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09b481f1ef | ||
|
|
db97c60af1 | ||
|
|
2318b51630 | ||
|
|
03d1da5423 | ||
|
|
646044cd81 | ||
|
|
35ed4417f0 | ||
|
|
9167013ac4 | ||
|
|
b028270cc4 | ||
|
|
22f87a493a | ||
|
|
5156f7de89 | ||
|
|
93e9594bad | ||
|
|
16a22316b2 | ||
|
|
c549bdb03f | ||
|
|
c9938a36ea | ||
|
|
6c5d7d1ef0 | ||
|
|
1a0d8713e4 | ||
|
|
c9a4e68157 | ||
|
|
7e8ea63230 | ||
|
|
47e725a4d8 | ||
|
|
920eed52d7 | ||
|
|
fcb4562bd5 | ||
|
|
4007bae1e2 | ||
|
|
8f21ad5be3 | ||
|
|
52f14cc5b5 | ||
|
|
7b86bafbac | ||
|
|
000a8acdb9 | ||
|
|
55fd6ad9bd | ||
|
|
ca992d169c | ||
|
|
99bed510de | ||
|
|
a898c06114 | ||
|
|
094657efa0 | ||
|
|
1376b55931 | ||
|
|
d852f3dc3a | ||
|
|
94e46da234 | ||
|
|
49753ccb88 | ||
|
|
bc572cc2ea | ||
|
|
2c19d00d17 | ||
|
|
b60b5121aa | ||
|
|
408d1c760a | ||
|
|
700e9aa0e5 | ||
|
|
bfb0c82ef9 | ||
|
|
7dab62733a | ||
|
|
821ec7c5f7 | ||
|
|
309df1ab61 | ||
|
|
2d2954709d | ||
|
|
4200871ee9 | ||
|
|
77f3c0cc05 | ||
|
|
8e5bb2ea9a | ||
|
|
b79ae8e7f1 | ||
|
|
f33ce0bb98 | ||
|
|
389fbd28fe | ||
|
|
657fee0de6 | ||
|
|
c02dda308e | ||
|
|
a49f38216a | ||
|
|
2325b7c9c3 | ||
|
|
b8c7470ace | ||
|
|
28bd6bdcfd | ||
|
|
dcec0c10ec | ||
|
|
17a173a685 | ||
|
|
b43dc1e48f | ||
|
|
53cd03886c | ||
|
|
5cc181f1e2 | ||
|
|
1965d4bd7e | ||
|
|
2e943b2ebc | ||
|
|
aad0c4d7f8 | ||
|
|
bd5ae8702a | ||
|
|
d85401fb49 | ||
|
|
79d2e0328a | ||
|
|
6d9dfd970c | ||
|
|
d0c2a39140 | ||
|
|
498c35188f | ||
|
|
6eafa5932d | ||
|
|
7247d0ad5b | ||
|
|
af91abab01 | ||
|
|
7569fba7b4 | ||
|
|
862fd8449d | ||
|
|
94d5fa3084 | ||
|
|
e07a38f634 | ||
|
|
e907773b5f | ||
|
|
e9bc8e0f31 | ||
|
|
44b2b8cb79 | ||
|
|
efeb9b54b5 | ||
|
|
cbc59aaf8b | ||
|
|
cb6d68958d | ||
|
|
e865a7f5b0 | ||
|
|
83eaebc357 | ||
|
|
e62e3bd1ea | ||
|
|
9b69c9f08e | ||
|
|
5f58dbfa3d | ||
|
|
229d93b54b | ||
|
|
119b330e60 | ||
|
|
13669b0d86 | ||
|
|
e8e869dc40 | ||
|
|
0e3a4fdd18 | ||
|
|
0f1eb56e66 | ||
|
|
4b2a9c3b0d | ||
|
|
4a6aa4875e | ||
|
|
daa6cfa532 | ||
|
|
286c8012a9 | ||
|
|
55f8e5e72c | ||
|
|
13f15ca1ef | ||
|
|
87184eddde | ||
|
|
f34559a0e9 | ||
|
|
1a792ecfec | ||
|
|
daee720738 | ||
|
|
8abe570374 | ||
|
|
643f329d9d | ||
|
|
ae70c02149 | ||
|
|
aaac810f4f | ||
|
|
0d7f8cef10 | ||
|
|
6edd361ed3 | ||
|
|
6fc4583a15 | ||
|
|
fa4442d836 | ||
|
|
8602db88de | ||
|
|
5bde4e0b4a | ||
|
|
2a43265e12 | ||
|
|
1a953a17ab | ||
|
|
b692ec7b44 | ||
|
|
e311649525 | ||
|
|
b6ad8971a8 | ||
|
|
1699cc7986 | ||
|
|
33538fa9e0 | ||
|
|
5859ac29cd | ||
|
|
141adf5468 | ||
|
|
46de1a3623 | ||
|
|
3562ad10d5 | ||
|
|
6ebbae0326 | ||
|
|
9f0223591f | ||
|
|
97d6e8a479 | ||
|
|
1774414f2e | ||
|
|
5dd7b2c809 | ||
|
|
a5dfe9fe42 | ||
|
|
2176160ac0 | ||
|
|
57cc39dbdf | ||
|
|
fe93be379a | ||
|
|
44f62a3c14 | ||
|
|
03de6f3519 | ||
|
|
b073dc2160 | ||
|
|
70185ae86f | ||
|
|
79bf154ce3 | ||
|
|
29f9eb0cbc | ||
|
|
c7ced41bcb | ||
|
|
9f81a1e40d | ||
|
|
9ca1413a1f | ||
|
|
79e04ed3cc | ||
|
|
a306340119 | ||
|
|
4fdc9babbe | ||
|
|
199f95fdbd | ||
|
|
3b0e569882 | ||
|
|
6f4f6b988b | ||
|
|
adce27c0d1 | ||
|
|
b9ca8ed3eb | ||
|
|
4f567ecd5f | ||
|
|
44560b9792 | ||
|
|
bf5bd8c417 | ||
|
|
d465f427a4 | ||
|
|
1c3626e0a0 | ||
|
|
31d822e7d6 | ||
|
|
6288cd3cc1 | ||
|
|
5b79da2db6 | ||
|
|
00ef72c45e | ||
|
|
e99d5ac4a0 | ||
|
|
2807a16d1e | ||
|
|
56f91a96b7 | ||
|
|
95d6922218 | ||
|
|
4fad876944 | ||
|
|
3410aaf5b9 | ||
|
|
11511df8c3 | ||
|
|
363f8c5aae | ||
|
|
a21c0242a6 | ||
|
|
172d086c23 | ||
|
|
889c262edc | ||
|
|
c382c17a92 | ||
|
|
a105d884cb | ||
|
|
9ed5cb26da | ||
|
|
ad05bc5ed5 | ||
|
|
01b59a7a0c | ||
|
|
6f60b151d0 | ||
|
|
1dae188cdf | ||
|
|
6a904a1849 | ||
|
|
538701c753 | ||
|
|
c6e64b1da8 | ||
|
|
ff0578cb88 | ||
|
|
b5fd346437 | ||
|
|
7c67df4b30 | ||
|
|
8eddcb15ca | ||
|
|
97026d7bbb | ||
|
|
56f52218ed | ||
|
|
05896f354d | ||
|
|
dcb77dc6b1 | ||
|
|
41544a6840 | ||
|
|
bf87a80617 | ||
|
|
c8153c0d18 | ||
|
|
e4f0713121 | ||
|
|
c5ff1ca59a | ||
|
|
d3f01f27c6 | ||
|
|
7fad2f2202 | ||
|
|
5edc9f7d85 | ||
|
|
3b53b9bdcc | ||
|
|
e4ad09830b | ||
|
|
120cc8e901 | ||
|
|
9998e7ae3f | ||
|
|
18831c20fa | ||
|
|
2e3f7bf827 | ||
|
|
b3b478be10 | ||
|
|
da5650ceb4 | ||
|
|
c1e6204686 | ||
|
|
86a04bc8a2 | ||
|
|
28261af55c | ||
|
|
2e8f6ffc35 | ||
|
|
92f60bd02f | ||
|
|
f5915c4c31 | ||
|
|
80342a74e8 | ||
|
|
c7720d3847 | ||
|
|
8f4c937e92 | ||
|
|
c06fa67ccc | ||
|
|
156a5f3218 | ||
|
|
fb93d2d85f | ||
|
|
d4aa9b1644 | ||
|
|
f64338a3b8 | ||
|
|
5e937fa816 | ||
|
|
5797ee18c0 | ||
|
|
e03e373caa | ||
|
|
71c257669a | ||
|
|
32a04b69b0 | ||
|
|
a3fffd482f | ||
|
|
57b002917f | ||
|
|
9800927b59 | ||
|
|
bd3bb8c774 | ||
|
|
6340976e1c | ||
|
|
a0048554c6 | ||
|
|
bd29257b73 | ||
|
|
4640d8479c | ||
|
|
ecad00c854 | ||
|
|
43a88808a5 | ||
|
|
df62505b59 | ||
|
|
bbc7dc95a1 | ||
|
|
525ba27e3b | ||
|
|
711c73bbe7 | ||
|
|
8f2585d8eb | ||
|
|
42a8edf8ed | ||
|
|
a355ed79d0 | ||
|
|
11d0bd794e | ||
|
|
11f39c27f4 | ||
|
|
8827a07b11 | ||
|
|
3f8b596361 | ||
|
|
3eb9ac20b0 | ||
|
|
fbb13a33d5 | ||
|
|
41494d7c16 | ||
|
|
96340a35aa | ||
|
|
57d11299da | ||
|
|
4658a3961f | ||
|
|
15a580aeef | ||
|
|
c54fdcd9fb | ||
|
|
62926f88d5 | ||
|
|
80f774dd0b | ||
|
|
79a44ba546 | ||
|
|
5fd4067ba4 | ||
|
|
3484ff5ccf | ||
|
|
5b13598bd0 | ||
|
|
dfe4fba212 | ||
|
|
3c150d91e0 | ||
|
|
b79276f236 | ||
|
|
673f65d052 | ||
|
|
7f2e684572 | ||
|
|
d814ac7f80 | ||
|
|
3f6ac31262 | ||
|
|
ea1c64d2b3 | ||
|
|
0721c39f89 | ||
|
|
d5cef681e4 | ||
|
|
773b21500f | ||
|
|
c3bc911bcd | ||
|
|
35a916a3a5 | ||
|
|
b251812b9e | ||
|
|
e9bdf8aefc | ||
|
|
7dbd65b4d0 | ||
|
|
719b3d1895 | ||
|
|
c39bb8124c | ||
|
|
83b2c51301 | ||
|
|
c76c233aae | ||
|
|
354f1c28cd | ||
|
|
10cc96d6e8 | ||
|
|
7a26fccff7 | ||
|
|
32d1916ec0 | ||
|
|
290d72f2b6 | ||
|
|
0f312a13f0 | ||
|
|
5fd686a00a | ||
|
|
90b00ac557 | ||
|
|
62dd3ca90e | ||
|
|
f3c4571cc0 | ||
|
|
58c56a8082 | ||
|
|
6dff0844bb | ||
|
|
fa63a98e77 | ||
|
|
a06c9f8614 | ||
|
|
d8e380efae | ||
|
|
21cfa62cc2 | ||
|
|
79b1e53d07 | ||
|
|
1a390b5a1f | ||
|
|
6325099e2e | ||
|
|
8c62e90711 | ||
|
|
b22184940b | ||
|
|
67b0ae78a5 | ||
|
|
81a35d9dba | ||
|
|
85cbe20b39 | ||
|
|
6703f1bd20 | ||
|
|
7025009788 | ||
|
|
3054cd801e | ||
|
|
adba24b5de | ||
|
|
f27c47dae2 | ||
|
|
25e9779f73 | ||
|
|
c1c7704c32 | ||
|
|
b1d709fc4a | ||
|
|
4863381dbb | ||
|
|
dabc520090 | ||
|
|
fe4920465d | ||
|
|
34175988a5 | ||
|
|
352dd6e93f | ||
|
|
7cef3f329e | ||
|
|
a85ba40637 | ||
|
|
7b03863eaa | ||
|
|
8db6ccfdc3 | ||
|
|
703fcd0e02 | ||
|
|
a277b5e753 | ||
|
|
7ad709c38e | ||
|
|
562aa787d6 | ||
|
|
fa43821cf4 | ||
|
|
bc49c71ea0 | ||
|
|
bcfc33f35c | ||
|
|
9703ebfcaf | ||
|
|
0cb28a4f3a | ||
|
|
5428bc9a03 | ||
|
|
8e849a7bbe | ||
|
|
c0b1af3d0f | ||
|
|
32d5e6f1dc | ||
|
|
3dbe9c1c52 | ||
|
|
521784b359 | ||
|
|
33b060b551 | ||
|
|
974de992f0 | ||
|
|
02a737fad6 | ||
|
|
893e7d3875 | ||
|
|
53a891a71f | ||
|
|
fc1c23314b | ||
|
|
15d56715b9 | ||
|
|
4a34408db9 | ||
|
|
d615fc9cc0 | ||
|
|
ea0660e717 | ||
|
|
e1d07f99f5 | ||
|
|
92d07b22fa | ||
|
|
61588db474 | ||
|
|
84b24e347f | ||
|
|
5a38aaf3e8 | ||
|
|
6cd5d1687e | ||
|
|
c8f45408b1 | ||
|
|
b7f0b33d3a | ||
|
|
c26460ae02 | ||
|
|
793b215457 | ||
|
|
f0d312827e | ||
|
|
8607ce84ba | ||
|
|
37a20ebf34 | ||
|
|
34a6f37041 | ||
|
|
648ae6c1dd | ||
|
|
13c771aa29 | ||
|
|
ff15bddfc8 | ||
|
|
ffe62bf1fd | ||
|
|
b89644f5f7 | ||
|
|
9e0a919cb8 | ||
|
|
ae6641270a | ||
|
|
3a983c5767 | ||
|
|
6189f0cb84 | ||
|
|
13585a9a9c | ||
|
|
b079ae8742 | ||
|
|
054887bad8 | ||
|
|
f7ce1f4be6 | ||
|
|
1a99064fc7 | ||
|
|
2a8dbac89d | ||
|
|
67e767237b | ||
|
|
8ddf03d139 | ||
|
|
1d99434a25 | ||
|
|
911c80db0e | ||
|
|
c332fb2ff1 | ||
|
|
47adcca1e1 | ||
|
|
42f6cf9d7f | ||
|
|
4dfec917c8 | ||
|
|
e3c7bd5346 | ||
|
|
be1a2ec914 | ||
|
|
426e4f0994 | ||
|
|
5b0a7d0ce9 | ||
|
|
96b35e957c | ||
|
|
dac81b2c3b | ||
|
|
ce86f4ec2f | ||
|
|
95daa47a5b | ||
|
|
9fb8d4b37e | ||
|
|
4082a48154 | ||
|
|
9ad1214e96 | ||
|
|
bc8909bab0 | ||
|
|
632ee884e9 | ||
|
|
fb2bb64d65 | ||
|
|
4f62e6828b | ||
|
|
dc8b6ee37e | ||
|
|
6d44919f5b | ||
|
|
3e5355a71d | ||
|
|
390f264987 | ||
|
|
43029e88e6 | ||
|
|
73d8ce56a2 | ||
|
|
f79451b615 | ||
|
|
06d2665b70 | ||
|
|
46e30897ae | ||
|
|
ab04764212 | ||
|
|
e3056d70c0 | ||
|
|
5013b32ca9 | ||
|
|
e07bee5a75 | ||
|
|
51a06cf0b7 | ||
|
|
fddadbe44a | ||
|
|
a13109970d | ||
|
|
db26b673ee | ||
|
|
39e43fa923 | ||
|
|
5501141ae0 | ||
|
|
c64ea0a021 | ||
|
|
83fbcca557 | ||
|
|
8358073420 | ||
|
|
ae67fa4344 | ||
|
|
9dfebd73db | ||
|
|
c2f60d6ab2 | ||
|
|
e3310a34fd | ||
|
|
41945e6ff0 | ||
|
|
5ec02c99fd | ||
|
|
e769a03634 | ||
|
|
216352c39d | ||
|
|
94b28b2107 | ||
|
|
829fb52a04 | ||
|
|
dd856ac01f | ||
|
|
ce71de29f7 | ||
|
|
66012e9fe9 | ||
|
|
d164b3f9bc | ||
|
|
654d385b6d | ||
|
|
e40fd106d7 | ||
|
|
c7e20a9590 | ||
|
|
cbe7a2fc95 | ||
|
|
fbcc289e92 | ||
|
|
0306ed4f34 | ||
|
|
d975b846be | ||
|
|
e313112d7c | ||
|
|
5dc6369b8f | ||
|
|
68407e3b47 | ||
|
|
eef831fe60 | ||
|
|
171483a1da | ||
|
|
423feb8b77 | ||
|
|
9733806610 | ||
|
|
95385daaf9 | ||
|
|
eeaabf487f | ||
|
|
7ee61a0440 | ||
|
|
fe5417bf97 | ||
|
|
123a24ce34 | ||
|
|
55b2d68def | ||
|
|
c663e8ede0 | ||
|
|
d73976537d | ||
|
|
54c96bd737 | ||
|
|
7c183b0c8d | ||
|
|
1b9f76be23 | ||
|
|
fa1978d587 | ||
|
|
0c7505b588 | ||
|
|
903a4584a8 | ||
|
|
ab555f25f5 | ||
|
|
7f434a34e7 | ||
|
|
743bbd0b1c | ||
|
|
2a11d52d3c | ||
|
|
8767cfdea3 | ||
|
|
3f8c236a72 | ||
|
|
e96330e50b | ||
|
|
b2febcee88 | ||
|
|
a174ac5185 | ||
|
|
4511cef619 | ||
|
|
7a628f2fec | ||
|
|
b4142c7f1f | ||
|
|
04ea2a3162 | ||
|
|
466c8176a1 | ||
|
|
02cc6301e0 | ||
|
|
282ac0fe5a | ||
|
|
cf53c65cfe | ||
|
|
292ca4532b | ||
|
|
a4ce8cc979 | ||
|
|
80d897a022 | ||
|
|
c3f5739fea | ||
|
|
da7fdd0f23 | ||
|
|
9535ca8940 | ||
|
|
f633509848 | ||
|
|
59ee530d54 | ||
|
|
7881210785 | ||
|
|
2869a2b192 | ||
|
|
20342351a5 | ||
|
|
af4dd175c4 | ||
|
|
f99cb46b26 | ||
|
|
e8862d5128 | ||
|
|
c4966b9a28 | ||
|
|
fc3a00b4b9 | ||
|
|
4a6fe922f3 | ||
|
|
1486121b77 | ||
|
|
c61347defe | ||
|
|
ad28cc710c | ||
|
|
3b2241e891 | ||
|
|
4030396203 | ||
|
|
7e65450eac | ||
|
|
6546943cf7 | ||
|
|
2e4b0b04ed | ||
|
|
2b0bb5af74 | ||
|
|
633a506753 | ||
|
|
1178fc254a | ||
|
|
7061a96cce | ||
|
|
4eda850110 | ||
|
|
c2609643da | ||
|
|
277dbdf4e1 | ||
|
|
a4ef2ef29c | ||
|
|
9c7567e62e | ||
|
|
df4aa4b25d | ||
|
|
382d29e03f | ||
|
|
4786b1f250 | ||
|
|
b96bf180f9 | ||
|
|
deba156711 | ||
|
|
1a398b16ef | ||
|
|
861eaaeaef | ||
|
|
96d59d55ff | ||
|
|
779717217f | ||
|
|
eba0d36015 | ||
|
|
73c91b9509 | ||
|
|
3967ef8922 | ||
|
|
ad77fd38c5 | ||
|
|
63faf4cceb | ||
|
|
97c301f510 | ||
|
|
c1e56188d6 | ||
|
|
0969c0641e | ||
|
|
bea1bbf1a8 | ||
|
|
998ee606b1 | ||
|
|
ba518269b4 | ||
|
|
6b54c70d26 | ||
|
|
c20e9defde | ||
|
|
9b8f98ec7c | ||
|
|
b131554efe | ||
|
|
a804782476 | ||
|
|
cc45f477fb | ||
|
|
9518d386d0 | ||
|
|
4b81b7f97a | ||
|
|
f47819a31e | ||
|
|
91f3f270d4 | ||
|
|
905ccf5f17 | ||
|
|
8b3dc71dc6 | ||
|
|
bbf5918896 | ||
|
|
e380408b28 | ||
|
|
8a55414143 | ||
|
|
b62b15a21f | ||
|
|
5b62118336 | ||
|
|
6b77eb93c1 | ||
|
|
202c8edc53 | ||
|
|
33b5814b7a | ||
|
|
cedc6552ce | ||
|
|
926dfe3cf0 | ||
|
|
0b44b850f2 | ||
|
|
0aecaf50f8 | ||
|
|
b1234ff548 | ||
|
|
85ddf3ca38 | ||
|
|
3248f37932 | ||
|
|
4c7786a0d9 | ||
|
|
686e2e78ec | ||
|
|
7f4bd96739 | ||
|
|
63d6649ab4 | ||
|
|
2ec3141431 | ||
|
|
9c103180cd | ||
|
|
550520795f | ||
|
|
3ea2aed9a4 | ||
|
|
02d5466812 | ||
|
|
28abd3c593 | ||
|
|
846cd976c0 | ||
|
|
6fe6e049ad | ||
|
|
941be1b98f | ||
|
|
47c40fb9f3 | ||
|
|
23c397fe7b | ||
|
|
cb7b01ad81 | ||
|
|
37c8c79310 | ||
|
|
7106be4b07 | ||
|
|
a7604d9026 | ||
|
|
26520991cf | ||
|
|
e92266c025 | ||
|
|
16c55a4105 | ||
|
|
f13f3063c9 | ||
|
|
65afdfb92e | ||
|
|
49c42ba169 | ||
|
|
c9c02d993e | ||
|
|
fa2e3fa0b2 | ||
|
|
2458b471ed | ||
|
|
8e61ab24bf | ||
|
|
440c73961e | ||
|
|
d3e2dce27b | ||
|
|
7243d841a3 | ||
|
|
848cffee07 | ||
|
|
98706acfa5 | ||
|
|
b4f080ff32 | ||
|
|
c5a3368b9f | ||
|
|
4acfafb998 | ||
|
|
9795e2ce8a | ||
|
|
b86f2cdb11 | ||
|
|
72af724588 | ||
|
|
b86a6bf4d1 | ||
|
|
763d290055 | ||
|
|
b34ef896c7 | ||
|
|
67c91b0559 | ||
|
|
b418aa3899 | ||
|
|
1d1a91108e | ||
|
|
8bf2220cea | ||
|
|
62e1b752af | ||
|
|
a0d1c6f77c | ||
|
|
335f914f32 | ||
|
|
32c503527b | ||
|
|
bff8b1112c | ||
|
|
325ce1730d | ||
|
|
7ee0e8f2f4 | ||
|
|
ee7bd3e776 | ||
|
|
c92d2d28b5 | ||
|
|
9025aae3d1 | ||
|
|
4d0419c536 | ||
|
|
4cb5932fd0 |
260
.agents/skills/android-readme-screenshot-studio/SKILL.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
name: android-readme-screenshot-studio
|
||||||
|
description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Android emulator captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab.
|
||||||
|
compatibility: Requires git, gh, the Android SDK and emulator, adb, Java/Gradle, Python 3, and image inspection support. FFmpeg is useful for capture-only media preparation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Android README Screenshot Studio
|
||||||
|
|
||||||
|
Create repository screenshots from the real Bitchat Android UI, not from a
|
||||||
|
drawn mockup. The result should look intentional enough for the top of the
|
||||||
|
README while remaining reproducible, synthetic, and honest about what a static
|
||||||
|
emulator capture proves.
|
||||||
|
|
||||||
|
## Resolve the brief
|
||||||
|
|
||||||
|
Extract as much as possible from the conversation before asking questions.
|
||||||
|
Confirm or infer:
|
||||||
|
|
||||||
|
1. Which surfaces are needed, such as mesh chat and geohash globe.
|
||||||
|
2. The exact visible state and ordering. Treat phrases like “photo, messages,
|
||||||
|
three voices, thumbs-up” as a chronological contract rather than a loose
|
||||||
|
suggestion.
|
||||||
|
3. Whether existing README images should be preserved, replaced, or added.
|
||||||
|
4. The target base. Default repository work to the latest `origin/main`.
|
||||||
|
5. Whether GitHub publication and merge are authorized. A request to “open a
|
||||||
|
PR and merge it” authorizes both; otherwise do not merge.
|
||||||
|
|
||||||
|
Do not invent the subject of a requested photo. Nicknames, channel names, or
|
||||||
|
previous fixture copy are not sufficient justification for choosing an outdoor,
|
||||||
|
urban, political, or personal scene. Reuse an existing rights-safe asset when
|
||||||
|
the subject should remain stable, or ask for the intended subject. If the user
|
||||||
|
explicitly approves synthetic imagery, disclose it and keep its source
|
||||||
|
capture-only unless they request a committed asset.
|
||||||
|
|
||||||
|
Read [references/showcase-recipes.md](references/showcase-recipes.md) for every
|
||||||
|
run. It contains the concrete mesh-chat and globe recipes, framing guidance,
|
||||||
|
the verified current-pair fast path, and the final acceptance checklist. When
|
||||||
|
the request matches the existing README pair and the production UI has not
|
||||||
|
materially changed, try that fast path first and then validate every visible
|
||||||
|
result. Fall back to tracing the current implementation when an entry point,
|
||||||
|
state model, or composition has changed.
|
||||||
|
|
||||||
|
When a populated screen requires a debug fixture, also read
|
||||||
|
[../android-ui-visual-review/references/fixture-recipes.md](../android-ui-visual-review/references/fixture-recipes.md).
|
||||||
|
|
||||||
|
## Work in a fresh tree
|
||||||
|
|
||||||
|
Protect the user's active checkout:
|
||||||
|
|
||||||
|
1. Inspect `git status` without modifying it.
|
||||||
|
2. Fetch `origin/main`.
|
||||||
|
3. Create a new `codex/` branch in a temporary worktree rooted at the current
|
||||||
|
`origin/main`.
|
||||||
|
4. Keep screenshots and raw captures in a separate temporary artifact
|
||||||
|
directory so cleanup or checkout operations cannot remove them.
|
||||||
|
|
||||||
|
Do not switch the user's original checkout, reuse a dirty branch, or mix an
|
||||||
|
unrelated PR into the screenshot change. If the request continues an existing
|
||||||
|
screenshot PR, reuse its already-isolated worktree only after verifying its
|
||||||
|
head and base.
|
||||||
|
|
||||||
|
## Establish the capture contract
|
||||||
|
|
||||||
|
Before building, write a compact local matrix containing:
|
||||||
|
|
||||||
|
- surface and navigation path;
|
||||||
|
- chronological fixture contents;
|
||||||
|
- expected visible top and bottom rows;
|
||||||
|
- emulator profile class, portrait orientation, theme, and locale;
|
||||||
|
- crop policy and final asset dimensions;
|
||||||
|
- existing asset path and README reference;
|
||||||
|
- behaviors the static screenshot does not prove.
|
||||||
|
|
||||||
|
Use the current production UI and latest `main` interaction model. Trace the
|
||||||
|
screen entry point and state source before adding a fixture. A beautiful capture
|
||||||
|
of a stale or fake UI is not acceptable.
|
||||||
|
|
||||||
|
## Use a high-resolution Android canvas
|
||||||
|
|
||||||
|
Prefer the newest stable Android runtime installed locally and a large,
|
||||||
|
high-density portrait emulator profile. Reuse a previously validated capture
|
||||||
|
profile when it remains available, but verify the guest properties and keep
|
||||||
|
the resulting profile facts in the local capture contract rather than the
|
||||||
|
repository or GitHub text.
|
||||||
|
|
||||||
|
After boot, record the guest values with an explicit emulator selector:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.release
|
||||||
|
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.sdk
|
||||||
|
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.security_patch
|
||||||
|
adb -s "$ANDROID_README_SERIAL" shell wm size
|
||||||
|
adb -s "$ANDROID_README_SERIAL" shell wm density
|
||||||
|
```
|
||||||
|
|
||||||
|
Never publish emulator selectors, AVD names, local paths, usernames, IP
|
||||||
|
addresses, or other machine identifiers.
|
||||||
|
|
||||||
|
## Build a deterministic showcase fixture
|
||||||
|
|
||||||
|
Launch the Activity before injecting process-local state. Prefer existing debug
|
||||||
|
hooks. If they cannot express the composition, add the smallest temporary
|
||||||
|
command under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/debug/java/com/bitchat/android/testhook/
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixture should:
|
||||||
|
|
||||||
|
- use synthetic names, peer IDs, message IDs, and copy;
|
||||||
|
- use the real local mesh peer ID for self-authored messages;
|
||||||
|
- use a fixed epoch so timestamps and ordering are stable;
|
||||||
|
- insert records in the exact requested chronology;
|
||||||
|
- report structured counts through the test-hook result file;
|
||||||
|
- copy capture-only media into the app's cache or files directory;
|
||||||
|
- populate only the peers and state needed for the header;
|
||||||
|
- avoid persistence unless persistence itself is the subject.
|
||||||
|
|
||||||
|
For voice notes, route real audio files through the app's waveform extractor.
|
||||||
|
Use short, distinct, locally synthesized speech clips or other rights-safe
|
||||||
|
speech audio. Never draw a decorative waveform and call it speech. Wait for
|
||||||
|
asynchronous decoding before capture, then inspect that pauses and syllable
|
||||||
|
envelopes look plausibly different between notes.
|
||||||
|
|
||||||
|
For image attachments, use a rights-safe existing asset or an explicitly
|
||||||
|
approved synthetic source. Keep fixture media outside production source sets
|
||||||
|
and remove every capture-only hook before committing. Compose image rows may
|
||||||
|
remember a decoded bitmap by file path. After replacing the bytes at an
|
||||||
|
unchanged path, relaunch the app or use a new destination path before judging
|
||||||
|
the revised crop.
|
||||||
|
|
||||||
|
## Capture from the real app
|
||||||
|
|
||||||
|
Build and install the ABI-matching debug APK, satisfy onboarding and
|
||||||
|
permissions, inject the fixture, and navigate to the intended surface.
|
||||||
|
|
||||||
|
Capture directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s "$ANDROID_README_SERIAL" exec-out screencap -p > "$ARTIFACT_PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the full screenshot immediately. Check message count and order,
|
||||||
|
nickname ownership, peer count, waveform variety, image visibility, globe
|
||||||
|
center, grid precision, clipping, and composer placement.
|
||||||
|
|
||||||
|
Inject a complete timeline in one operation and allow at least two seconds of
|
||||||
|
quiet UI time after the fixture reports success. This avoids capturing entry
|
||||||
|
animations, incomplete placement, or media that has not finished decoding.
|
||||||
|
|
||||||
|
Crop only Android system chrome. Preserve Bitchat's app header, translucent
|
||||||
|
overlap, content, and composer. Derive the crop from the observed status and
|
||||||
|
navigation insets; do not blindly reuse pixel offsets from a different profile.
|
||||||
|
Because the app renders edge-to-edge, app controls may extend into the reported
|
||||||
|
navigation inset. Place the bottom crop after the final control outline and
|
||||||
|
shadow but before the system gesture affordance; removing the entire inset can
|
||||||
|
clip the app itself.
|
||||||
|
Keep every final README screenshot in a matched portrait size.
|
||||||
|
|
||||||
|
Use image inspection after the crop. File dimensions and a successful ADB
|
||||||
|
command do not prove that the desired composition is visible.
|
||||||
|
|
||||||
|
## Update repository assets
|
||||||
|
|
||||||
|
Discover the current README references before writing. Prefer stable paths under
|
||||||
|
`docs/screenshots/` and replace only the assets the user requested.
|
||||||
|
|
||||||
|
When adding a showcase section:
|
||||||
|
|
||||||
|
- keep the layout readable on GitHub;
|
||||||
|
- give every image meaningful alt text;
|
||||||
|
- use relative repository paths;
|
||||||
|
- avoid machine-generated cache files or capture sources;
|
||||||
|
- keep paired screenshots at identical dimensions.
|
||||||
|
|
||||||
|
Run the bundled validator for every final asset:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 \
|
||||||
|
.agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py \
|
||||||
|
--repo-root . \
|
||||||
|
--readme README.md \
|
||||||
|
--require-same-size \
|
||||||
|
--asset docs/screenshots/readme-mesh-chat.png \
|
||||||
|
--asset docs/screenshots/readme-geohash-globe.png
|
||||||
|
```
|
||||||
|
|
||||||
|
When replacing only one image in an existing pair, pass both the changed and
|
||||||
|
unchanged assets with `--require-same-size`, and verify the unchanged asset's
|
||||||
|
checksum. Pass only one asset and omit `--require-same-size` only when the
|
||||||
|
README has no paired screenshot to preserve.
|
||||||
|
|
||||||
|
## Remove the fixture and verify cleanly
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Remove temporary imports, commands, helpers, resources, and fixture media
|
||||||
|
with a focused patch.
|
||||||
|
2. Verify `git diff -- app/src/debug` is empty.
|
||||||
|
3. Verify `git status --short` lists only the intended README and screenshot
|
||||||
|
files.
|
||||||
|
4. Run `git diff --check`.
|
||||||
|
5. Run `./gradlew assembleDebug` after fixture removal.
|
||||||
|
6. Re-run the screenshot validator.
|
||||||
|
7. Confirm the user's original checkout is still untouched.
|
||||||
|
|
||||||
|
The final commit must not contain synthetic peer data, generated photo sources,
|
||||||
|
audio clips, ADB outputs, emulator configuration, or local capture reports
|
||||||
|
unless the user separately requested those artifacts in the repository.
|
||||||
|
|
||||||
|
## Record honest evidence
|
||||||
|
|
||||||
|
Use the capture manifest and report format from
|
||||||
|
`../android-ui-visual-review/` when before/after evidence is useful. For a
|
||||||
|
README-only change with no production UI delta, identical before/after images
|
||||||
|
are acceptable when explicitly labeled “no production UI delta.”
|
||||||
|
|
||||||
|
State limitations plainly:
|
||||||
|
|
||||||
|
- a populated mesh timeline proves rendering, not physical message delivery;
|
||||||
|
- a voice row proves waveform rendering, not audio playback;
|
||||||
|
- an attachment proves image rendering, not media transfer;
|
||||||
|
- a globe proves picker state, not live location or relay behavior.
|
||||||
|
|
||||||
|
## Commit, publish, and optionally merge
|
||||||
|
|
||||||
|
GitHub writes require user authorization. When authorized:
|
||||||
|
|
||||||
|
1. Stage only intended files.
|
||||||
|
2. Commit without overriding author or committer identity.
|
||||||
|
3. Push the `codex/` branch.
|
||||||
|
4. Use `gh pr create` or update the existing PR.
|
||||||
|
5. Describe the exact capture sequence, synthetic fixture disclosure,
|
||||||
|
repository-safe validation commands, and limitations. Keep emulator,
|
||||||
|
runtime, hardware, and local-environment facts out of GitHub text whenever
|
||||||
|
repository privacy rules classify them as machine identifiers.
|
||||||
|
6. Verify the PR head and checks with `gh pr view` and `gh pr checks`.
|
||||||
|
7. Merge only when the user explicitly requested it and required checks allow
|
||||||
|
it. Prefer the repository's normal merge strategy and use `gh`.
|
||||||
|
8. Verify the merged state and resulting `main` commit.
|
||||||
|
|
||||||
|
When GitHub publication is not authorized, leave the finished commit or local
|
||||||
|
change in the isolated worktree and hand back its branch and artifact paths.
|
||||||
|
Do not silently push it.
|
||||||
|
|
||||||
|
Do not place local paths, device selectors, generated-image paths, or personal
|
||||||
|
machine details in commits, PR text, comments, or merge messages.
|
||||||
|
|
||||||
|
## Final handoff
|
||||||
|
|
||||||
|
Lead with the outcome and include:
|
||||||
|
|
||||||
|
- PR and merge URL or status;
|
||||||
|
- final screenshot paths and dimensions;
|
||||||
|
- one-line composition summary per surface;
|
||||||
|
- build and validator results;
|
||||||
|
- synthetic media disclosure;
|
||||||
|
- current CI state or merged commit;
|
||||||
|
- confirmation that the original checkout was not modified.
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "android-readme-screenshot-studio",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Android emulator, open a PR, and merge it after checks pass.",
|
||||||
|
"expected_output": "The agent works in a fresh latest-main worktree, tries the documented current-pair fast path before rediscovering the fixture, captures the real app on a verified large portrait emulator profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses a fresh worktree rooted at current origin/main and leaves the original checkout untouched.",
|
||||||
|
"Captures from the real Android UI on a verified high-resolution portrait emulator profile.",
|
||||||
|
"Uses the verified nine-row chat fixture and synthetic thky globe seed as fast starting points when the current UI still matches, while visually validating the result.",
|
||||||
|
"Waits for fixture completion and quiet UI time, and invalidates the media path cache when replacing image bytes at the same destination.",
|
||||||
|
"Keeps the requested Middle East focus while preserving whole-Earth zoom.",
|
||||||
|
"Removes temporary fixtures and passes a clean debug build before committing.",
|
||||||
|
"Uses gh for the authorized PR and merge without publishing machine identifiers."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Replace only the README chat image. It should read top to bottom as: a photo, four short messages back and forth, three voice messages back and forth, then one thumbs-up. Keep the current globe exactly as-is and make the speech waveforms look natural.",
|
||||||
|
"expected_output": "The agent treats the requested order and counts as exact, asks or resolves the photo subject instead of inventing it, uses the real waveform extractor on three distinct speech clips, preserves the globe asset, visually inspects the final crop, and leaves only the chat PNG changed.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Implements exactly one photo, four alternating texts, three alternating voice notes, and one final thumbs-up.",
|
||||||
|
"Does not infer a photo subject from nicknames or unrelated fixture copy.",
|
||||||
|
"Produces waveforms from real rights-safe speech audio through the app extractor rather than drawing bars.",
|
||||||
|
"Preserves the globe asset and verifies that only the chat screenshot remains in the diff.",
|
||||||
|
"Removes the debug fixture before the final clean build."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Show me before and after screenshots for the Compose header changes in PR #412 at 320 dp and 411 dp, and post the comparison as a PR comment.",
|
||||||
|
"expected_output": "The agent recognizes that this is visual regression evidence rather than README marketing capture and routes the task to android-ui-visual-review instead of applying the README screenshot workflow.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Routes the request to android-ui-visual-review.",
|
||||||
|
"Does not replace README assets.",
|
||||||
|
"Uses the PR merge-base and paired before/after capture workflow.",
|
||||||
|
"Publishes only the requested PR comment rather than opening an unrelated screenshot PR."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"prompt": "The bottom of one or both README showcase screenshots is cropped. Correct the images without changing their content, update the screenshot skill so this does not recur, and open a new PR.",
|
||||||
|
"expected_output": "The agent inspects both merged assets at full size, identifies every affected screen, recaptures from the real app with the existing deterministic fixtures, and treats the navigation inset as an inspection region rather than an automatic crop. The final crop preserves complete app controls while excluding the system gesture affordance, keeps the pair matched, adds a reusable edge-to-edge guardrail to the skill, validates and visually inspects both images, and opens a sanitized PR.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Inspects both screenshots and corrects every affected asset rather than assuming only one is cropped.",
|
||||||
|
"Recaptures the real app with the established deterministic synthetic content instead of padding or compositing the merged PNGs.",
|
||||||
|
"Uses accessibility bounds and full-size pixel inspection to place the bottom crop after complete app controls and before system gesture chrome.",
|
||||||
|
"Verifies the entire chat composer border and bottom padding plus every globe action-button corner and shadow are visible.",
|
||||||
|
"Keeps paired screenshots at identical dimensions and passes the screenshot validator and clean debug build.",
|
||||||
|
"Updates the skill with a general edge-to-edge crop rule and publishes no device or machine identifiers."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,271 @@
|
|||||||
|
# README showcase recipes
|
||||||
|
|
||||||
|
Use these recipes as composition guidance, then adapt them to the user's exact
|
||||||
|
request and the current UI. The requested chronology and framing always win over
|
||||||
|
the examples.
|
||||||
|
|
||||||
|
## Verified current-pair fast path
|
||||||
|
|
||||||
|
Use this baseline first when refreshing the existing README mesh-chat and
|
||||||
|
geohash-globe pair without a material production UI change. It records a
|
||||||
|
known-good capture, not a permanent UI contract: verify entry points, visible
|
||||||
|
state, insets, and output dimensions on every run.
|
||||||
|
|
||||||
|
### One-pass app preparation
|
||||||
|
|
||||||
|
Build and install the ABI-matching debug APK. A generic `app-debug.apk` may not
|
||||||
|
exist when the project emits ABI splits, so resolve the installed emulator ABI
|
||||||
|
and select the matching output before searching for alternate build tasks.
|
||||||
|
|
||||||
|
Use normal onboarding or a temporary debug preparation command to:
|
||||||
|
|
||||||
|
- mark onboarding complete;
|
||||||
|
- set the synthetic nickname `trailhead`;
|
||||||
|
- select the production `ChatUiMode.Bubbles` presentation;
|
||||||
|
- grant only the runtime permissions needed to reach the surface; and
|
||||||
|
- keep BLE and Wi-Fi Aware debug transport disabled during deterministic
|
||||||
|
rendering.
|
||||||
|
|
||||||
|
Launch the target Activity before injecting process-local state. A preparation
|
||||||
|
command may use the existing `PermissionManager` and `AppStateStore` APIs, but
|
||||||
|
must remain capture-only and be removed before the clean build.
|
||||||
|
|
||||||
|
### Exact chat fixture used for the current pair
|
||||||
|
|
||||||
|
Clear the in-memory showcase state, add four synthetic peers, and use the real
|
||||||
|
local mesh peer ID only to mark self-authored rows. Use `solace` for the remote
|
||||||
|
sender and insert these nine rows at one-minute intervals in one operation:
|
||||||
|
|
||||||
|
| Order | Sender | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `trailhead` | mountain image |
|
||||||
|
| 2 | `solace` | `That view is unreal.` |
|
||||||
|
| 3 | `trailhead` | `Worth the climb.` |
|
||||||
|
| 4 | `solace` | `How's the signal up there?` |
|
||||||
|
| 5 | `trailhead` | `Still holding strong.` |
|
||||||
|
| 6 | `trailhead` | voice note |
|
||||||
|
| 7 | `solace` | voice note |
|
||||||
|
| 8 | `trailhead` | voice note |
|
||||||
|
| 9 | `solace` | `👍` |
|
||||||
|
|
||||||
|
A fixed epoch such as `1767258000000` keeps ordering stable. The displayed
|
||||||
|
clock text is locale- and time-zone-dependent, so validate consistency rather
|
||||||
|
than promising a specific rendered hour.
|
||||||
|
|
||||||
|
Reuse the existing rights-safe mountain subject when the brief has not changed.
|
||||||
|
A near-square source crop around 840×800 produced enough image height while
|
||||||
|
leaving the reaction above the composer. If the crop changes, relaunch the app
|
||||||
|
or change the cache destination path; the image row can retain the previous
|
||||||
|
bitmap when the path is reused.
|
||||||
|
|
||||||
|
Generate three local, rights-safe speech clips with visibly different cadence,
|
||||||
|
then transcode them to the app's normal M4A/AAC path. This known-good synthetic
|
||||||
|
set used roughly 170, 220, and 145 words per minute:
|
||||||
|
|
||||||
|
1. `The trail is clear. I can hear you.`
|
||||||
|
2. `Copy that. Sending one back now.`
|
||||||
|
3. `Perfect. The mesh is still holding strong.`
|
||||||
|
|
||||||
|
Run each file through `AudioWaveformExtractor` and cache its 120-bin result via
|
||||||
|
`VoiceWaveformCache`. Wait for the fixture's structured success result, then
|
||||||
|
allow at least two additional seconds for Compose placement and media decoding
|
||||||
|
before capture.
|
||||||
|
|
||||||
|
### Exact globe path used for the current pair
|
||||||
|
|
||||||
|
Start the production, non-exported `GeohashPickerActivity` through a temporary
|
||||||
|
in-app debug command. Seed it with the explicitly synthetic geohash `thky`,
|
||||||
|
wait about three seconds for the camera to settle, then invoke the production
|
||||||
|
minus control three times with about one second between changes. The verified
|
||||||
|
result was precision 1 with label `#t`, the whole Earth visible, and the Arabian
|
||||||
|
Peninsula/Persian Gulf region beneath the center crosshair.
|
||||||
|
|
||||||
|
Treat `thky` as a fast starting point, not a substitute for inspection. Reject
|
||||||
|
the result if geography, camera distance, grid, label, or controls differ from
|
||||||
|
the brief. Never source the seed from device location, IP-derived location, or
|
||||||
|
account data.
|
||||||
|
|
||||||
|
### Capture and crop baseline
|
||||||
|
|
||||||
|
Capture the full screen only after the UI has been still for at least two
|
||||||
|
seconds. Re-observe the status and navigation insets, then remove only those
|
||||||
|
bands while preserving all app UI. Reuse prior offsets only when the local
|
||||||
|
profile and measured insets still match.
|
||||||
|
|
||||||
|
Keep both outputs at identical dimensions. Run the screenshot validator and
|
||||||
|
inspect both images at full size. Keep raw captures, generated audio, fixture
|
||||||
|
media, profile facts, and device output local; only the final PNG assets belong
|
||||||
|
in the repository.
|
||||||
|
|
||||||
|
## Mesh-chat showcase
|
||||||
|
|
||||||
|
### Visual goal
|
||||||
|
|
||||||
|
Make the screen read as a real conversation at a glance:
|
||||||
|
|
||||||
|
1. one strong media anchor near the top;
|
||||||
|
2. a short text exchange with alternating senders;
|
||||||
|
3. a compact voice-note exchange with visibly different speech envelopes;
|
||||||
|
4. a small final reaction or acknowledgement;
|
||||||
|
5. the app header and composer framing the timeline.
|
||||||
|
|
||||||
|
Avoid stuffing every supported feature into one frame. The screenshot should
|
||||||
|
show capability through hierarchy, not through maximum item count.
|
||||||
|
|
||||||
|
### Known-good fixture shape
|
||||||
|
|
||||||
|
For a request like “photo, messages, three voices, thumbs-up,” use exactly:
|
||||||
|
|
||||||
|
| Order | Type | Sender |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Image | self or remote, according to the story |
|
||||||
|
| 2 | Short text | other sender |
|
||||||
|
| 3 | Short text | alternating sender |
|
||||||
|
| 4 | Short text | alternating sender |
|
||||||
|
| 5 | Short text | alternating sender |
|
||||||
|
| 6 | Voice note | sender A |
|
||||||
|
| 7 | Voice note | sender B |
|
||||||
|
| 8 | Voice note | sender A |
|
||||||
|
| 9 | `👍` | sender B |
|
||||||
|
|
||||||
|
Keep copy conversational and concise. Use synthetic names and avoid real
|
||||||
|
locations, contacts, identities, or sensitive content.
|
||||||
|
|
||||||
|
For public mesh rendering, pass the current mesh peer ID as `senderPeerID` on
|
||||||
|
self-authored messages. This exercises the same ownership and color path as
|
||||||
|
production. Alternate sender IDs so every back-and-forth row renders its sender
|
||||||
|
header instead of being grouped away.
|
||||||
|
|
||||||
|
### Photo framing
|
||||||
|
|
||||||
|
A portrait or tall crop can let the latest rows stay visible while the older
|
||||||
|
photo slides partially behind Bitchat's translucent header. This is visually
|
||||||
|
useful only when the photo subject remains legible.
|
||||||
|
|
||||||
|
- Preserve the app's own rounded image treatment.
|
||||||
|
- Do not bake UI chrome into the photo.
|
||||||
|
- Avoid important content under the header overlap.
|
||||||
|
- Do not select a photo subject from nicknames alone.
|
||||||
|
- If generating a synthetic photo, obtain or infer subject approval first,
|
||||||
|
disclose generation, and keep the source outside the final commit unless
|
||||||
|
requested.
|
||||||
|
|
||||||
|
### Natural voice rows
|
||||||
|
|
||||||
|
Prepare three short speech clips with different durations, pauses, and cadence.
|
||||||
|
Locally available offline TTS plus an audio transcoder is sufficient. Use the
|
||||||
|
same audio format the app normally records or plays, such as M4A.
|
||||||
|
|
||||||
|
After copying the files into an app-readable location, create
|
||||||
|
`BitchatMessageType.Audio` messages that point to those actual files. Let
|
||||||
|
`AudioWaveformExtractor` and `VoiceWaveformCache` produce the bars.
|
||||||
|
|
||||||
|
Reject the capture when:
|
||||||
|
|
||||||
|
- all three envelopes look identical;
|
||||||
|
- the bars are uniform or sinusoidal rather than speech-like;
|
||||||
|
- duration labels are missing or implausible;
|
||||||
|
- a waveform is clipped by the screen edge;
|
||||||
|
- a temporary progress or cancel state is visible.
|
||||||
|
|
||||||
|
### Layout tuning
|
||||||
|
|
||||||
|
Inject the complete fixture in one operation so the list adopts it as history
|
||||||
|
instead of animating rows during capture. Let the reverse-layout list settle at
|
||||||
|
the newest message.
|
||||||
|
|
||||||
|
If the oldest photo is not partially visible, prefer changing its aspect ratio
|
||||||
|
or the number of short text rows over manually scrolling to an unstable offset.
|
||||||
|
If the newest reaction falls behind the composer, shorten earlier content or
|
||||||
|
reduce media height. Do not crop app content to solve a fixture problem.
|
||||||
|
|
||||||
|
## Geohash globe showcase
|
||||||
|
|
||||||
|
### Visual goal
|
||||||
|
|
||||||
|
Show:
|
||||||
|
|
||||||
|
- the entire Earth;
|
||||||
|
- a readable geohash grid;
|
||||||
|
- the selected coarse cell;
|
||||||
|
- the requested geographic focus beneath the selection crosshair;
|
||||||
|
- the picker hint and precision controls.
|
||||||
|
|
||||||
|
Use the current picker Activity and renderer. Do not composite a globe or grid
|
||||||
|
outside the app.
|
||||||
|
|
||||||
|
### Focus without zoom
|
||||||
|
|
||||||
|
When a user says “focus on the Middle East, no zoom”:
|
||||||
|
|
||||||
|
1. open or seed the picker at a geohash centered on the requested area;
|
||||||
|
2. allow the globe to center on that location;
|
||||||
|
3. reduce precision to the same coarsest whole-Earth level used for the
|
||||||
|
showcase;
|
||||||
|
4. preserve camera distance while confirming the center moved;
|
||||||
|
5. capture with the whole globe still visible.
|
||||||
|
|
||||||
|
The exact seed may change with picker implementation. Validate against visible
|
||||||
|
geography and the selected geohash label rather than assuming the seed worked.
|
||||||
|
|
||||||
|
Reject the capture when:
|
||||||
|
|
||||||
|
- the requested region is off-center;
|
||||||
|
- Earth is clipped;
|
||||||
|
- reducing precision also changed the camera distance against the brief;
|
||||||
|
- the selected cell or crosshair is illegible;
|
||||||
|
- controls overlap the globe;
|
||||||
|
- stale system bars remain in the final README asset.
|
||||||
|
|
||||||
|
## Crop and output
|
||||||
|
|
||||||
|
Capture the full physical screen first. Determine the status-bar and
|
||||||
|
navigation-bar insets from the current profile, then crop those insets only.
|
||||||
|
|
||||||
|
Treat the navigation inset as an inspection region, not an automatic crop
|
||||||
|
amount. Bitchat uses edge-to-edge layout, so the composer's border, globe-button
|
||||||
|
corners, or their shadows may occupy part of that region. Use accessibility
|
||||||
|
bounds plus full-size pixel inspection to place the crop below every app
|
||||||
|
control and above the system gesture affordance.
|
||||||
|
|
||||||
|
Treat previously observed crop offsets as local evidence, not as a universal
|
||||||
|
rule or repository documentation. Re-measure when the profile changes.
|
||||||
|
|
||||||
|
Every paired README image should:
|
||||||
|
|
||||||
|
- be a valid PNG;
|
||||||
|
- share width and height;
|
||||||
|
- retain the app header and composer or controls;
|
||||||
|
- exclude Android status and gesture/navigation chrome;
|
||||||
|
- remain sharp at GitHub's rendered width.
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
### Chat
|
||||||
|
|
||||||
|
- [ ] Media subject matches the user's brief.
|
||||||
|
- [ ] Message chronology exactly matches the requested sequence.
|
||||||
|
- [ ] Sender ownership and alternation are correct.
|
||||||
|
- [ ] Voice-note count is exact.
|
||||||
|
- [ ] Waveforms were extracted from real speech audio and look distinct.
|
||||||
|
- [ ] Final reaction is visible above the composer.
|
||||||
|
- [ ] Header peer count and nickname are synthetic and intentional.
|
||||||
|
- [ ] The complete composer border and bottom padding are visible.
|
||||||
|
|
||||||
|
### Globe
|
||||||
|
|
||||||
|
- [ ] Requested region is centered.
|
||||||
|
- [ ] Zoom level matches the brief.
|
||||||
|
- [ ] Whole Earth and grid are visible.
|
||||||
|
- [ ] Selected cell and crosshair are legible.
|
||||||
|
- [ ] Hint and precision controls are unobstructed.
|
||||||
|
- [ ] Every action-button corner and shadow is fully visible.
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
|
||||||
|
- [ ] Only requested README and PNG assets remain in the diff.
|
||||||
|
- [ ] Temporary debug fixture and media are removed.
|
||||||
|
- [ ] Final clean debug build passes.
|
||||||
|
- [ ] Screenshot validator passes.
|
||||||
|
- [ ] PR text contains no machine or personal identifiers.
|
||||||
|
- [ ] Static-capture limitations are disclosed.
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate high-resolution README screenshot assets without external packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def png_dimensions(path: Path) -> tuple[int, int]:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
header = handle.read(24)
|
||||||
|
if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
|
||||||
|
fail(f"{path.name} is not a valid PNG with an IHDR header")
|
||||||
|
width, height = struct.unpack(">II", header[16:24])
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
fail(f"{path.name} has invalid dimensions: {width}x{height}")
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_inside(root: Path, value: Path, label: str) -> tuple[Path, Path]:
|
||||||
|
candidate = value if value.is_absolute() else root / value
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
try:
|
||||||
|
relative = resolved.relative_to(root)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{label} must stay inside the repository") from exc
|
||||||
|
if not resolved.is_file():
|
||||||
|
fail(f"{label} does not exist: {relative.as_posix()}")
|
||||||
|
return resolved, relative
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate README PNG references, dimensions, and repository-safe paths."
|
||||||
|
)
|
||||||
|
parser.add_argument("--repo-root", type=Path, default=Path("."))
|
||||||
|
parser.add_argument("--readme", type=Path, default=Path("README.md"))
|
||||||
|
parser.add_argument("--asset", action="append", type=Path, required=True)
|
||||||
|
parser.add_argument("--min-width", type=int, default=1080)
|
||||||
|
parser.add_argument("--min-height", type=int, default=1920)
|
||||||
|
parser.add_argument("--require-same-size", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = args.repo_root.resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
fail("repository root does not exist")
|
||||||
|
if args.min_width <= 0 or args.min_height <= 0:
|
||||||
|
fail("minimum dimensions must be positive")
|
||||||
|
|
||||||
|
readme_path, readme_relative = resolve_inside(root, args.readme, "README")
|
||||||
|
readme_text = readme_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assets: list[dict[str, object]] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
dimensions: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
|
for index, value in enumerate(args.asset):
|
||||||
|
asset_path, relative = resolve_inside(root, value, f"asset[{index}]")
|
||||||
|
if asset_path in seen:
|
||||||
|
fail(f"duplicate asset: {relative.as_posix()}")
|
||||||
|
seen.add(asset_path)
|
||||||
|
|
||||||
|
if asset_path.suffix.lower() != ".png":
|
||||||
|
fail(f"README screenshot must be a PNG: {relative.as_posix()}")
|
||||||
|
if relative.parts[:2] != ("docs", "screenshots"):
|
||||||
|
fail(
|
||||||
|
"README screenshots must live under docs/screenshots: "
|
||||||
|
f"{relative.as_posix()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
reference = relative.as_posix()
|
||||||
|
if reference not in readme_text and f"./{reference}" not in readme_text:
|
||||||
|
fail(f"README does not reference asset: {reference}")
|
||||||
|
|
||||||
|
width, height = png_dimensions(asset_path)
|
||||||
|
if width < args.min_width or height < args.min_height:
|
||||||
|
fail(
|
||||||
|
f"{reference} is below the high-resolution minimum: "
|
||||||
|
f"{width}x{height} < {args.min_width}x{args.min_height}"
|
||||||
|
)
|
||||||
|
dimensions.add((width, height))
|
||||||
|
assets.append({"path": reference, "width": width, "height": height})
|
||||||
|
|
||||||
|
if args.require_same_size and len(dimensions) != 1:
|
||||||
|
rendered = ", ".join(f"{width}x{height}" for width, height in sorted(dimensions))
|
||||||
|
fail(f"README screenshots do not share one size: {rendered}")
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"readme": readme_relative.as_posix(),
|
||||||
|
"assets": assets,
|
||||||
|
"same_size": len(dimensions) == 1,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
255
.agents/skills/android-ui-visual-review/SKILL.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
name: android-ui-visual-review
|
||||||
|
description: Analyze an Android pull request, branch, commit, or patch for user-visible changes and produce reproducible before/after screenshots from isolated builds. Use this skill whenever a user asks for PR screenshots, branch UI comparisons, visual regression evidence, Compose before/after captures, populated message-state screenshots, responsive/theme/locale comparisons, or GitHub comments containing Android UI evidence—even if they only say “show me what changed.” Also use it to determine and document that a suspected UI PR has no visual delta. Do not use it for implementing a new UI, ordinary code review without visual evidence, or physical mesh validation.
|
||||||
|
compatibility: Requires git, the Android SDK and emulator, adb, Java/Gradle, Python 3, and gh for pull-request resolution or publishing.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Android UI Visual Review
|
||||||
|
|
||||||
|
Turn a PR or branch into trustworthy visual evidence. The comparison is useful
|
||||||
|
only when the before and after builds use the correct commits, Android runtime,
|
||||||
|
viewport, app state, and navigation path.
|
||||||
|
|
||||||
|
## Collect the two user choices
|
||||||
|
|
||||||
|
Before starting, resolve:
|
||||||
|
|
||||||
|
1. **Target** — a PR URL/number or a branch/commit. If it is missing, ask for it.
|
||||||
|
For a branch, also ask for the intended base when it cannot be inferred
|
||||||
|
safely; otherwise default to the repository's `main`.
|
||||||
|
2. **Publishing** — for a PR target, ask whether the final screenshots and
|
||||||
|
findings should stay local or be posted as a PR comment. Do not perform any
|
||||||
|
GitHub write unless the user explicitly chooses publishing. A prior explicit
|
||||||
|
request such as “post these to the PR” already answers this question.
|
||||||
|
|
||||||
|
Do not block on publishing preference while doing read-only analysis if the user
|
||||||
|
has not answered yet. Keep the local workflow useful on its own.
|
||||||
|
|
||||||
|
## Read the routed guidance
|
||||||
|
|
||||||
|
- Read [references/analysis-playbook.md](references/analysis-playbook.md) for
|
||||||
|
every run. It explains how to map a diff to screens, states, and a capture
|
||||||
|
matrix.
|
||||||
|
- Read [references/fixture-recipes.md](references/fixture-recipes.md) whenever
|
||||||
|
the affected screen needs messages, peers, channels, nicknames, settings,
|
||||||
|
permissions, locale, theme, onboarding state, or another non-empty fixture.
|
||||||
|
- Read [references/github-publishing.md](references/github-publishing.md) only
|
||||||
|
when the user has opted into a PR comment.
|
||||||
|
|
||||||
|
## Create an isolated review session
|
||||||
|
|
||||||
|
Never switch the user's active checkout between before and after revisions.
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
|
||||||
|
--target "<PR URL, PR number, branch, or commit>"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a branch with a non-default base:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
|
||||||
|
--target "<branch>" \
|
||||||
|
--base "<base ref>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The script fetches a PR head when needed, computes the **actual merge-base**,
|
||||||
|
creates a detached temporary worktree at the before SHA, and prints:
|
||||||
|
|
||||||
|
- session directory
|
||||||
|
- worktree path
|
||||||
|
- artifact directory
|
||||||
|
- before and after SHAs
|
||||||
|
- PR number/base metadata when applicable
|
||||||
|
|
||||||
|
Keep artifacts outside the worktree so checkouts cannot remove them. The script
|
||||||
|
may symlink the ignored `local.properties` into the temporary worktree; never
|
||||||
|
publish it or quote its contents.
|
||||||
|
|
||||||
|
If a review session already exists and its SHAs are verified, reuse it. Do not
|
||||||
|
create a second worktree for the same run.
|
||||||
|
|
||||||
|
## Establish the visual contract
|
||||||
|
|
||||||
|
Use the actual diff, not the PR title, to determine what should be visible.
|
||||||
|
|
||||||
|
1. Record `git diff --stat`, `--name-status`, and the focused diff between the
|
||||||
|
before and after SHAs.
|
||||||
|
2. Trace changed UI symbols to their composable/activity, state source, entry
|
||||||
|
point, and prerequisites.
|
||||||
|
3. Separate direct visual changes from indirect ones such as dynamic color,
|
||||||
|
locale recreation, launcher resources, default data, or backend state shown
|
||||||
|
by an otherwise unchanged screen.
|
||||||
|
4. Produce a local capture matrix before building. Each row should define:
|
||||||
|
screen, navigation path, fixture, logical width, theme, locale, permissions,
|
||||||
|
and what difference is expected.
|
||||||
|
5. Include a control state where the UI should remain unchanged when that helps
|
||||||
|
distinguish intentional degradation from a regression.
|
||||||
|
|
||||||
|
When the diff contains no UI/resource/state-to-UI change, say so. If the user
|
||||||
|
asked for a screenshot for every target, capture the nearest affected surface
|
||||||
|
before and after and label the expected result **no visual delta**. Do not invent
|
||||||
|
a UI claim for a behavioral fix.
|
||||||
|
|
||||||
|
## Use the newest stable Android runtime
|
||||||
|
|
||||||
|
Prefer an emulator for repeatable UI evidence. At run time:
|
||||||
|
|
||||||
|
1. Inspect installed SDK/system images and current official Android release
|
||||||
|
information. Choose the newest **stable** API; do not silently use a preview.
|
||||||
|
2. Create a dedicated AVD and isolated data directory when possible. If current
|
||||||
|
command-line tools cannot create a newly versioned image (for example an
|
||||||
|
extension image), a verified `-sysdir` override with an isolated data
|
||||||
|
directory is acceptable.
|
||||||
|
3. After boot, record guest properties rather than trusting the AVD name:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.release
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.sdk
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.security_patch
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell wm size
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell wm density
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an explicit emulator serial for every ADB command when any physical device
|
||||||
|
is also connected. Never put serials, device names, local paths, IP addresses,
|
||||||
|
or other machine identifiers into reports or GitHub comments.
|
||||||
|
|
||||||
|
Use a physical device only when the affected UI depends on hardware that the
|
||||||
|
emulator cannot reproduce. Ask before changing or clearing a physical device.
|
||||||
|
This skill does not replace Mesh Lab: if the diff changes mesh, transport,
|
||||||
|
crypto, service, or physical peer behavior, use the `mesh-lab` skill separately
|
||||||
|
before claiming the behavior works.
|
||||||
|
|
||||||
|
## Build and capture the before state
|
||||||
|
|
||||||
|
The worktree starts at the before SHA.
|
||||||
|
|
||||||
|
1. Build the debug APK with `./gradlew assembleDebug`.
|
||||||
|
2. Install the ABI-matching APK with `adb install -r`.
|
||||||
|
3. Complete stable prerequisites such as onboarding and permissions.
|
||||||
|
4. Apply the fixture from the capture matrix.
|
||||||
|
5. Navigate using semantic/UI-automator evidence where possible. Use coordinate
|
||||||
|
taps only after inspecting the current screen, and keep coordinates local.
|
||||||
|
6. Capture every matrix row with a descriptive name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
before-<surface>-<state>-<width>-<theme>.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `adb exec-out screencap -p` so the PNG is written directly to the artifact
|
||||||
|
directory. Inspect every screenshot immediately; a successful command is not
|
||||||
|
proof that the intended screen was visible.
|
||||||
|
|
||||||
|
## Build and capture the after state
|
||||||
|
|
||||||
|
Before switching commits:
|
||||||
|
|
||||||
|
- Preserve the artifact directory outside the worktree.
|
||||||
|
- Preserve only intentional app state.
|
||||||
|
- Record any temporary debug fixture patch.
|
||||||
|
|
||||||
|
Checkout the recorded after SHA in the detached worktree, reapply the same
|
||||||
|
debug-only fixture if needed, build, and install with `-r` when state
|
||||||
|
preservation is part of the comparison.
|
||||||
|
|
||||||
|
Replay the same navigation and capture matrix. Name files with the matching
|
||||||
|
`after-` prefix. If reinstalling cannot preserve the state, replay the fixture
|
||||||
|
from its recorded inputs rather than comparing different states.
|
||||||
|
|
||||||
|
For responsive captures, record logical width in dp. On a fixed-pixel emulator,
|
||||||
|
changing density is acceptable when the calculation is documented:
|
||||||
|
|
||||||
|
```text
|
||||||
|
density = physical_width_px × 160 / desired_width_dp
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore the original density/theme/locale after the matrix is complete.
|
||||||
|
|
||||||
|
## Use deterministic artificial data
|
||||||
|
|
||||||
|
Prefer existing debug hooks. When they cannot express the visual state, add the
|
||||||
|
smallest temporary command to
|
||||||
|
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`.
|
||||||
|
|
||||||
|
The fixture must:
|
||||||
|
|
||||||
|
- stay under `src/debug`
|
||||||
|
- use synthetic names/content and deterministic IDs
|
||||||
|
- use the current mesh peer ID for self-authored messages so alignment logic is
|
||||||
|
exercised correctly
|
||||||
|
- use a fixed fixture epoch passed to both builds
|
||||||
|
- return structured success data through the existing test-hook result file
|
||||||
|
- be logically identical in before and after builds
|
||||||
|
|
||||||
|
After the final capture, remove the temporary fixture with a focused patch and
|
||||||
|
verify that the review worktree has no tracked modifications. Never commit or
|
||||||
|
publish the fixture unless the user separately asks to productize it.
|
||||||
|
|
||||||
|
## Validate and report
|
||||||
|
|
||||||
|
Create `capture-manifest.json` in the artifact directory using
|
||||||
|
[assets/capture-manifest.example.json](assets/capture-manifest.example.json) as
|
||||||
|
the shape. Use paths relative to the manifest and omit device selectors and
|
||||||
|
local absolute paths.
|
||||||
|
|
||||||
|
Validate it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 \
|
||||||
|
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py \
|
||||||
|
"<artifact-directory>/capture-manifest.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write a Markdown report next to the manifest with:
|
||||||
|
|
||||||
|
- target and exact before/after SHAs
|
||||||
|
- verified Android release/API/security patch and viewport
|
||||||
|
- concise code analysis
|
||||||
|
- visual findings, including intentional non-changes
|
||||||
|
- side-by-side before/after tables
|
||||||
|
- fixture disclosure
|
||||||
|
- limitations and any hardware behavior not exercised
|
||||||
|
|
||||||
|
End the local run only after:
|
||||||
|
|
||||||
|
- every manifest image exists and is a valid PNG
|
||||||
|
- each before/after pair has matching pixel dimensions
|
||||||
|
- every screenshot has been visually inspected
|
||||||
|
- the worktree has no tracked fixture changes
|
||||||
|
- the user's original checkout remains untouched
|
||||||
|
|
||||||
|
Leave the review session and artifacts available for later inspection unless
|
||||||
|
the user asks for cleanup.
|
||||||
|
|
||||||
|
## Optionally publish to the PR
|
||||||
|
|
||||||
|
Only after explicit user approval, follow
|
||||||
|
[references/github-publishing.md](references/github-publishing.md).
|
||||||
|
|
||||||
|
The PR comment should contain:
|
||||||
|
|
||||||
|
- Android capture environment
|
||||||
|
- detected visual changes
|
||||||
|
- clear before/after labels
|
||||||
|
- all requested screenshots
|
||||||
|
- limitations such as “no visual delta” or “hardware race not reproduced”
|
||||||
|
|
||||||
|
Use `gh` for all GitHub reads and writes. Verify the posted comment by reading it
|
||||||
|
back and counting the expected image embeds. Do not expose local paths or
|
||||||
|
machine identifiers, do not override Git author/committer identity, and do not
|
||||||
|
push screenshot files to a source branch unless the user separately authorizes
|
||||||
|
that repository change.
|
||||||
|
|
||||||
|
## Final handoff
|
||||||
|
|
||||||
|
Give the user:
|
||||||
|
|
||||||
|
- report and artifact links
|
||||||
|
- screenshot count
|
||||||
|
- one-line result per surface
|
||||||
|
- PR comment URL when published
|
||||||
|
- explicit statement that the original checkout was not modified
|
||||||
|
- any remaining coverage limitation
|
||||||
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"target": "PR #000 or branch-name",
|
||||||
|
"before_sha": "40-character merge-base SHA",
|
||||||
|
"after_sha": "40-character target SHA",
|
||||||
|
"environment": {
|
||||||
|
"android_release": "stable release number",
|
||||||
|
"android_sdk": "API number",
|
||||||
|
"security_patch": "YYYY-MM-DD",
|
||||||
|
"resolution_px": "1080x1920",
|
||||||
|
"default_density_dpi": 420
|
||||||
|
},
|
||||||
|
"captures": [
|
||||||
|
{
|
||||||
|
"id": "surface-state-411dp-light",
|
||||||
|
"surface": "Human-readable surface",
|
||||||
|
"state": "Synthetic fixture and navigation state",
|
||||||
|
"expected_change": "Specific visual hypothesis",
|
||||||
|
"before": "before-surface-state-411dp-light.png",
|
||||||
|
"after": "after-surface-state-411dp-light.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extras": [
|
||||||
|
{
|
||||||
|
"id": "after-expanded-menu",
|
||||||
|
"role": "after-detail",
|
||||||
|
"file": "after-expanded-menu.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Static screenshots do not prove hardware behavior."
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<!-- android-ui-visual-review:TARGET:AFTER_SHA -->
|
||||||
|
## Android UI verification
|
||||||
|
|
||||||
|
Captured from clean before/after debug builds on **Android ANDROID_RELEASE
|
||||||
|
(API ANDROID_SDK)**. The before build is this target's actual merge-base.
|
||||||
|
|
||||||
|
### Visual changes detected
|
||||||
|
|
||||||
|
- FINDING_ONE
|
||||||
|
- FINDING_TWO
|
||||||
|
- LIMITATION_OR_INTENTIONAL_NON_CHANGE
|
||||||
|
|
||||||
|
### SURFACE_OR_STATE
|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
Fixture disclosure: FIXTURE_DESCRIPTION_OR_NONE.
|
||||||
|
|
||||||
41
.agents/skills/android-ui-visual-review/evals/evals.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "android-ui-visual-review",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "Review PR #813 and make before/after screenshots of its Android UI changes. Keep everything local; do not comment on GitHub.",
|
||||||
|
"expected_output": "The agent creates an isolated worktree, compares the PR head with its actual merge-base, discovers the header width thresholds, captures a wide control plus the changed compact widths on the newest stable Android emulator, and produces a validated local report without any GitHub write.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses the actual PR merge-base rather than current main or head~1.",
|
||||||
|
"Creates a capture matrix that includes a wide control and every changed width threshold.",
|
||||||
|
"Populates joined-channel state so the conditional count is visible before testing degradation.",
|
||||||
|
"Keeps artifacts local and performs no GitHub write."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Compare branch feature/message-redesign against main. The empty chat will not show the change, so give me populated light and dark screenshots and also show any fresh-install nickname change.",
|
||||||
|
"expected_output": "The agent traces message rendering and nickname generation, adds a temporary deterministic debug-only fixture with received/self short/wrapped messages, applies equivalent state to both builds, captures light/dark and fresh nickname states, removes the fixture, and validates paired PNGs.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses synthetic deterministic messages with the real local peer ID for self classification.",
|
||||||
|
"Uses one fixed fixture epoch and identical logical data in both builds.",
|
||||||
|
"Captures both light and dark populated message states plus the fresh nickname state.",
|
||||||
|
"Removes temporary debug fixture changes before handoff."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "This hotspot PR looks UI-related, but inspect it rather than assuming. Capture before/after and, after you show the evidence, post the screenshots and findings to the PR.",
|
||||||
|
"expected_output": "The agent determines whether the diff actually changes UI, captures the nearest affected hotspot surface even if equality is expected, labels behavioral limitations, asks or recognizes explicit publishing authorization, uploads safe screenshots without a source branch, posts through gh, and verifies the comment.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Does not invent a visual delta when the diff is backend-only.",
|
||||||
|
"Labels the screenshots as an expected no-visual-change comparison.",
|
||||||
|
"Does not claim that a static screenshot proves the hotspot ownership race.",
|
||||||
|
"Posts only after explicit authorization and verifies image count and comment URL."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
# Diff-to-screen analysis playbook
|
||||||
|
|
||||||
|
Use this reference to turn a code diff into a minimal but complete screenshot
|
||||||
|
matrix.
|
||||||
|
|
||||||
|
## Resolve the correct comparison
|
||||||
|
|
||||||
|
For a PR, “before” is the merge-base of the fetched PR head and its configured
|
||||||
|
base branch. It is not necessarily the PR's current `baseRefOid`, the local
|
||||||
|
`main`, or the commit immediately preceding the head.
|
||||||
|
|
||||||
|
For a branch or commit, confirm the intended base. Compute the merge-base after
|
||||||
|
fetching both sides.
|
||||||
|
|
||||||
|
Record both SHAs before any checkout. Build each SHA rather than attempting to
|
||||||
|
reverse selected files on a single build.
|
||||||
|
|
||||||
|
## Triage the diff in layers
|
||||||
|
|
||||||
|
Start broad:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --stat "$BEFORE_SHA" "$AFTER_SHA"
|
||||||
|
git diff --name-status "$BEFORE_SHA" "$AFTER_SHA"
|
||||||
|
git diff "$BEFORE_SHA" "$AFTER_SHA" -- app/src/main app/src/debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Then classify changed files.
|
||||||
|
|
||||||
|
| Diff area | Likely visual impact |
|
||||||
|
|---|---|
|
||||||
|
| `ui/*.kt`, composables, modifiers, layouts | Direct screen/layout change |
|
||||||
|
| `ui/theme/*`, colors, shapes, typography | Cross-screen theme change |
|
||||||
|
| `res/values*`, drawables, mipmaps | Text, locale, icon, launcher, or palette |
|
||||||
|
| Manifest locale/theme/activity metadata | System or activity presentation |
|
||||||
|
| `DataManager`, preferences, defaults | Fresh-install/default-state change |
|
||||||
|
| `AppStateStore`, ViewModel/state flows | UI changes only after specific state |
|
||||||
|
| Service/transport/backend only | Usually no static UI delta; trace exposed state |
|
||||||
|
| `src/debug` or Android tests | Fixture/test mechanism, not production UI |
|
||||||
|
|
||||||
|
Do not stop at filenames. Search every changed public symbol and resource:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rg -n "<ChangedSymbol|resource_name>" app/src
|
||||||
|
```
|
||||||
|
|
||||||
|
Trace in both directions:
|
||||||
|
|
||||||
|
- Who calls or renders this code?
|
||||||
|
- Which state branch selects it?
|
||||||
|
- What user action reaches it?
|
||||||
|
- What permissions, onboarding, peers, channels, messages, theme, locale, or
|
||||||
|
width are required?
|
||||||
|
- Does the change affect an empty screen, only populated state, or both?
|
||||||
|
|
||||||
|
## Repository UI surface map
|
||||||
|
|
||||||
|
These are orientation points, not a substitute for inspecting the current
|
||||||
|
revision:
|
||||||
|
|
||||||
|
| Surface | Starting points |
|
||||||
|
|---|---|
|
||||||
|
| App launch/navigation/permissions | `MainActivity.kt`, `ui/ChatScreen.kt` |
|
||||||
|
| Top bar, nickname, peer/channel/location controls | `ui/ChatHeader.kt` |
|
||||||
|
| Message rows, bubbles, timestamps, media | `ui/MessageComponents.kt` |
|
||||||
|
| App state consumed by Compose | `services/AppStateStore.kt`, ViewModels |
|
||||||
|
| Default nickname/preferences | `ui/DataManager.kt` |
|
||||||
|
| About and Settings | `ui/AboutSheet.kt` |
|
||||||
|
| Location/geohash/channel controls | `ui/LocationChannelsSheet.kt` |
|
||||||
|
| Hotspot UI | `hotspot/HotspotActivity.kt` |
|
||||||
|
| Dynamic/fallback colors and shapes | `ui/theme/Theme.kt`, `ThemePreference.kt` |
|
||||||
|
| Debug ADB hooks | `src/debug/.../testhook/TestHookReceiver.kt`, `TestHookDriver.kt` |
|
||||||
|
|
||||||
|
Files and packages can move. Use `rg --files` and symbol search to re-establish
|
||||||
|
the current map at the target commits.
|
||||||
|
|
||||||
|
## Identify indirect UI changes
|
||||||
|
|
||||||
|
Some visual changes appear far away from the edited function:
|
||||||
|
|
||||||
|
- A nickname generator change is visible only after deleting or bypassing a
|
||||||
|
saved nickname.
|
||||||
|
- A Material You change appears only on Android 12+ and depends on the emulator
|
||||||
|
wallpaper/system palette.
|
||||||
|
- Locale selection may recreate the Activity and return to a different tab.
|
||||||
|
- A launcher background is not visible inside the running Activity.
|
||||||
|
- Message delivery status may appear only for self-authored private messages.
|
||||||
|
- A width policy may be invisible at the default device width.
|
||||||
|
- A backend ownership fix may produce no screenshot difference at all.
|
||||||
|
|
||||||
|
Write these as explicit hypotheses before capture. Each hypothesis needs either
|
||||||
|
a matrix row or a documented reason it cannot be shown statically.
|
||||||
|
|
||||||
|
## Build the capture matrix
|
||||||
|
|
||||||
|
Keep the matrix small enough to review but large enough to hit every changed
|
||||||
|
branch.
|
||||||
|
|
||||||
|
| Field | What to record |
|
||||||
|
|---|---|
|
||||||
|
| Surface | Human-readable screen/component |
|
||||||
|
| Entry path | Actions from launch to the target |
|
||||||
|
| Fixture | Messages, peer, channel, setting, or empty state |
|
||||||
|
| Platform | Android API feature needed, such as dynamic color |
|
||||||
|
| Width | Logical dp breakpoint |
|
||||||
|
| Theme | System/light/dark |
|
||||||
|
| Locale | System/default or selected locale |
|
||||||
|
| Expected before | Specific visual contract |
|
||||||
|
| Expected after | Specific visual contract |
|
||||||
|
| Control | State expected not to change, when useful |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Header crowding: 411 dp control, 380 dp middle breakpoint, 320 dp compact
|
||||||
|
breakpoint, with a joined channel so the count is actually present.
|
||||||
|
- Message bubbles: identical short/long received and self messages in light and
|
||||||
|
dark modes.
|
||||||
|
- Nickname prefix: fresh generated nickname in each build; explain that random
|
||||||
|
digits differ.
|
||||||
|
- Language picker: Settings before, Settings after, expanded menu, and one live
|
||||||
|
selection.
|
||||||
|
- Backend-only hotspot fix: the nearest hotspot surface before/after, explicitly
|
||||||
|
expecting equality.
|
||||||
|
|
||||||
|
## Distinguish visual proof from behavioral proof
|
||||||
|
|
||||||
|
A static screenshot can prove rendering, layout, labels, selected state, and
|
||||||
|
visible recreation. It cannot prove:
|
||||||
|
|
||||||
|
- foreign Wi-Fi group ownership
|
||||||
|
- BLE/Wi-Fi discovery or delivery
|
||||||
|
- Noise/identity correctness
|
||||||
|
- race avoidance
|
||||||
|
- background lifecycle behavior
|
||||||
|
- accessibility announcement content without an accessibility inspection
|
||||||
|
|
||||||
|
Name those limitations. Use relevant unit/instrumented tests or the repository's
|
||||||
|
Mesh Lab workflow separately.
|
||||||
|
|
||||||
|
## Compare carefully
|
||||||
|
|
||||||
|
Treat these as expected noise unless the PR changes them:
|
||||||
|
|
||||||
|
- status-bar clock
|
||||||
|
- battery/network indicator
|
||||||
|
- random nickname suffix
|
||||||
|
- dynamic palette when system wallpaper differs
|
||||||
|
- asynchronous peer counts
|
||||||
|
- animation frame
|
||||||
|
|
||||||
|
Stabilize or disclose the noise. Never describe it as a PR effect.
|
||||||
|
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
# Deterministic UI fixture recipes
|
||||||
|
|
||||||
|
Read current code before applying any recipe. These patterns intentionally use
|
||||||
|
the debug-only test-hook path and should be adapted to the APIs present at both
|
||||||
|
comparison SHAs.
|
||||||
|
|
||||||
|
## Fixture principles
|
||||||
|
|
||||||
|
1. Exercise the UI's real state classification. A self message must carry the
|
||||||
|
peer identity that production code uses to decide `isSelf`; changing only the
|
||||||
|
displayed sender nickname can produce a false layout.
|
||||||
|
2. Use the same logical records in both builds: stable IDs, sender IDs, content,
|
||||||
|
ordering, and one fixed epoch.
|
||||||
|
3. Keep content synthetic and review-safe. Do not use real messages, contacts,
|
||||||
|
peer IDs, channel memberships, device information, or locations.
|
||||||
|
4. Return structured success data and verify it before capturing.
|
||||||
|
5. Keep additions under `app/src/debug`; remove them after capture.
|
||||||
|
|
||||||
|
## Prefer the existing test hook
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt`
|
||||||
|
- `app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`
|
||||||
|
- `app/src/debug/AndroidManifest.xml`
|
||||||
|
|
||||||
|
The receiver accepts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell am broadcast \
|
||||||
|
-n com.bitchat.droid/com.bitchat.android.testhook.TestHookReceiver \
|
||||||
|
-a com.bitchat.droid.TEST_HOOK \
|
||||||
|
--es cmd "<command>" \
|
||||||
|
--es id "<unique-result-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the result rather than trusting broadcast delivery:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s "$ANDROID_REVIEW_SERIAL" shell run-as com.bitchat.droid \
|
||||||
|
cat "cache/testhook/results/<unique-result-id>.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing commands such as `set_nickname`, `broadcast_msg`, and state inspection
|
||||||
|
may already be sufficient.
|
||||||
|
|
||||||
|
## Public message fixture
|
||||||
|
|
||||||
|
When existing commands cannot create both received and self messages without a
|
||||||
|
second device, add a temporary `ui_fixture` command to `TestHookDriver`.
|
||||||
|
|
||||||
|
Adapt imports and constructor fields to the checked-out revision. The core shape
|
||||||
|
is:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun uiFixture(context: Context, intent: Intent): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val nickname = AppStateStore.nickname.value
|
||||||
|
.ifBlank { DataManager(context).loadNickname() }
|
||||||
|
val epochMs = intent.getLongExtra("fixture_epoch_ms", 1_800_000_000_000L)
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
BitchatMessage(
|
||||||
|
id = "ui-fixture-received-short",
|
||||||
|
sender = "mara",
|
||||||
|
content = "Are you seeing this?",
|
||||||
|
timestamp = Date(epochMs),
|
||||||
|
senderPeerID = "ui-fixture-peer",
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id = "ui-fixture-received-wrap",
|
||||||
|
sender = "mara",
|
||||||
|
content = "The mesh stays readable even when a message wraps onto a second line.",
|
||||||
|
timestamp = Date(epochMs + 60_000),
|
||||||
|
senderPeerID = "ui-fixture-peer",
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id = "ui-fixture-self-short",
|
||||||
|
sender = nickname,
|
||||||
|
content = "Yep — testing the message layout.",
|
||||||
|
timestamp = Date(epochMs + 120_000),
|
||||||
|
senderPeerID = mesh.myPeerID,
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id = "ui-fixture-self-wrap",
|
||||||
|
sender = nickname,
|
||||||
|
content = "Short and long bubbles should align consistently.",
|
||||||
|
timestamp = Date(epochMs + 180_000),
|
||||||
|
senderPeerID = mesh.myPeerID,
|
||||||
|
),
|
||||||
|
).forEach(AppStateStore::addPublicMessage)
|
||||||
|
|
||||||
|
return ok("ui_fixture")
|
||||||
|
.put("messages", 4)
|
||||||
|
.put("fixture_epoch_ms", epochMs)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why these details matter:
|
||||||
|
|
||||||
|
- received/self exercises both alignment branches
|
||||||
|
- short/wrapped content exercises intrinsic and capped width
|
||||||
|
- fixed IDs defeat accidental duplication
|
||||||
|
- a fixed epoch makes before/after timestamps comparable
|
||||||
|
- `mesh.myPeerID` exercises the real self-classification path
|
||||||
|
|
||||||
|
If `AppStateStore` moved packages or the message constructor changed, adapt only
|
||||||
|
the debug fixture. Do not modify production state logic to accommodate it.
|
||||||
|
|
||||||
|
App state is process-local. Launch the Activity before injection, inject after
|
||||||
|
the process is ready, and capture without force-stopping it.
|
||||||
|
|
||||||
|
## Private-message and delivery-status fixture
|
||||||
|
|
||||||
|
To inspect private-message bubbles or status placement:
|
||||||
|
|
||||||
|
- set the selected private peer in `AppStateStore`
|
||||||
|
- add messages using `addPrivateMessage`
|
||||||
|
- use the current local peer ID for self messages
|
||||||
|
- populate the delivery status explicitly when the changed component reads it
|
||||||
|
- mark read state consistently
|
||||||
|
|
||||||
|
Use a synthetic conversation ID such as `ui-fixture-private-peer`. Avoid writing
|
||||||
|
to persistent conversation storage unless persistence itself is being reviewed.
|
||||||
|
If persistence cannot be bypassed safely, use a unique deterministic fixture
|
||||||
|
conversation and disclose it in the report.
|
||||||
|
|
||||||
|
## Fresh default nickname
|
||||||
|
|
||||||
|
A saved preference hides generator changes. Add a temporary command that removes
|
||||||
|
only the nickname preference, invokes the revision's real generator, and updates
|
||||||
|
the in-memory store:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun resetNicknameFixture(context: Context): JSONObject {
|
||||||
|
context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.remove("nickname")
|
||||||
|
.commit()
|
||||||
|
val nickname = DataManager(context).loadNickname()
|
||||||
|
AppStateStore.setNickname(nickname)
|
||||||
|
return ok("reset_nickname_fixture").put("nickname", nickname)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
First inspect `DataManager` to confirm the preference file/key. Do not use this
|
||||||
|
recipe blindly after storage migrations.
|
||||||
|
|
||||||
|
Random suffixes are expected to differ. Compare the changed prefix/pattern, not
|
||||||
|
the digits. Do not seed or replace the production generator merely to make the
|
||||||
|
screenshot deterministic.
|
||||||
|
|
||||||
|
## Channel/header state
|
||||||
|
|
||||||
|
Responsive header screenshots need every conditional item present.
|
||||||
|
|
||||||
|
- Join a synthetic channel such as `#review` through the normal UI or existing
|
||||||
|
debug state API.
|
||||||
|
- Confirm the joined count is visible at the control width before changing
|
||||||
|
density.
|
||||||
|
- Keep the same nickname, channel, peer count, and location state in both builds.
|
||||||
|
- Capture a wide control and every threshold changed by the diff.
|
||||||
|
|
||||||
|
Do not use a real geohash/location. Synthetic channel state is enough for header
|
||||||
|
crowding unless the actual geohash label is the feature under review.
|
||||||
|
|
||||||
|
## Theme and dynamic color
|
||||||
|
|
||||||
|
- Force light and dark through `adb shell cmd uimode night no|yes`.
|
||||||
|
- Record the system wallpaper/palette only as anonymous environment context.
|
||||||
|
- Keep the same emulator data directory between builds so Material You input is
|
||||||
|
identical.
|
||||||
|
- Capture both themes when theme code, containers, surfaces, or contrast changes.
|
||||||
|
- Restore the original mode after capture.
|
||||||
|
|
||||||
|
## Locale
|
||||||
|
|
||||||
|
Use the new in-app picker when that is the feature. Capture:
|
||||||
|
|
||||||
|
1. Settings before
|
||||||
|
2. Settings with the new row
|
||||||
|
3. expanded picker
|
||||||
|
4. one live language selection
|
||||||
|
|
||||||
|
Locale application may recreate the Activity and reset the selected tab. This is
|
||||||
|
expected; navigate again rather than assuming the selection failed. Restore
|
||||||
|
System default after capture.
|
||||||
|
|
||||||
|
## Onboarding and permissions
|
||||||
|
|
||||||
|
Complete onboarding/permissions once in the before build, then use `adb install
|
||||||
|
-r` for after when signatures and data schemas are compatible.
|
||||||
|
|
||||||
|
If state cannot be preserved, record and replay each action. Do not clear all app
|
||||||
|
data merely to change one preference. Never run `pm clear` on a physical device
|
||||||
|
without explicit authorization.
|
||||||
|
|
||||||
|
## Remove the fixture
|
||||||
|
|
||||||
|
Use a focused patch to remove only additions made for capture. Then check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff -- app/src/debug
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
The finished worktree may contain untracked artifacts only when artifacts were
|
||||||
|
intentionally stored there. It must not contain tracked fixture changes.
|
||||||
|
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
# Publishing screenshots to a pull request
|
||||||
|
|
||||||
|
Follow this only after the user explicitly opts into a GitHub comment.
|
||||||
|
|
||||||
|
## Prepare a safe comment
|
||||||
|
|
||||||
|
The comment should contain:
|
||||||
|
|
||||||
|
- a unique hidden marker for idempotent verification
|
||||||
|
- verified Android release/API
|
||||||
|
- statement that before is the target's actual merge-base
|
||||||
|
- concise visual findings
|
||||||
|
- paired before/after image tables
|
||||||
|
- fixture disclosure when artificial state was used
|
||||||
|
- limitations and intentional non-changes
|
||||||
|
|
||||||
|
Do not include:
|
||||||
|
|
||||||
|
- local paths or usernames
|
||||||
|
- ADB serials, device names, peer IDs, addresses, or IPs
|
||||||
|
- real messages, contacts, locations, or account information
|
||||||
|
- build logs
|
||||||
|
- claims about physical behavior that screenshots do not prove
|
||||||
|
|
||||||
|
Scan the body locally for machine paths and selectors before posting.
|
||||||
|
Use [../assets/pr-comment-template.md](../assets/pr-comment-template.md) as the
|
||||||
|
starting structure, replacing every uppercase placeholder and adding one table
|
||||||
|
per comparison state.
|
||||||
|
|
||||||
|
## Upload image bytes without changing a source branch
|
||||||
|
|
||||||
|
Native `gh pr comment` accepts Markdown but not binary files. This skill bundles
|
||||||
|
an uploader that uses `gh api` to place images on a PR-scoped custom Git ref:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 \
|
||||||
|
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
|
||||||
|
--repo OWNER/REPO \
|
||||||
|
--pr NUMBER \
|
||||||
|
<ordered PNG files>
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper:
|
||||||
|
|
||||||
|
- uploads only image bytes and basenames
|
||||||
|
- writes to `refs/uploads/issues/<NUMBER>`, outside `refs/heads/*`
|
||||||
|
- does not pass an author or committer override
|
||||||
|
- prints JSON containing stable GitHub blob URLs
|
||||||
|
- does not post a comment
|
||||||
|
|
||||||
|
This is a GitHub repository write even though it does not create a visible
|
||||||
|
branch. The user's approval to publish the screenshots authorizes this
|
||||||
|
PR-scoped storage. If repository policy rejects custom refs, use an authenticated
|
||||||
|
GitHub web attachment composer or ask the user for an approved image host. Do
|
||||||
|
not fall back to a source branch without separate authorization.
|
||||||
|
|
||||||
|
Use `--dry-run` first when validating new inputs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 \
|
||||||
|
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
|
||||||
|
--dry-run \
|
||||||
|
--repo OWNER/REPO \
|
||||||
|
--pr NUMBER \
|
||||||
|
<ordered PNG files>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post through gh
|
||||||
|
|
||||||
|
Build the final Markdown body with the returned URLs. Keep before and after in
|
||||||
|
the same table row. Put extra after-state details, such as an expanded menu, in
|
||||||
|
a separate labeled table.
|
||||||
|
|
||||||
|
Post once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh pr comment NUMBER \
|
||||||
|
--repo OWNER/REPO \
|
||||||
|
--body-file "<validated-comment.md>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the returned comment URL.
|
||||||
|
|
||||||
|
## Verify the side effect
|
||||||
|
|
||||||
|
Read the comment back using `gh api` or `gh pr view`. Verify:
|
||||||
|
|
||||||
|
- the unique marker is present
|
||||||
|
- image embed count equals the requested screenshot count
|
||||||
|
- no local paths or identifiers were included
|
||||||
|
- the returned URL belongs to the intended PR
|
||||||
|
|
||||||
|
If the posting command's outcome is ambiguous, query for the marker before
|
||||||
|
retrying. Do not create duplicate comments.
|
||||||
|
|
||||||
|
Report the comment URL to the user. Leave the PR-scoped image ref in place while
|
||||||
|
the comment depends on it; deleting the ref can eventually break the images.
|
||||||
201
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh
Executable file
@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Create an isolated Android UI review worktree.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
create_review_worktree.sh --target <PR-URL|PR-NUMBER|BRANCH|COMMIT> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--base <REF> Base for a branch/commit target (default: main)
|
||||||
|
--repo-root <DIR> Repository root (default: current repository)
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
The script never removes an existing worktree. It prints and writes session.env
|
||||||
|
with the before/after SHAs and artifact paths.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
target=""
|
||||||
|
base_ref=""
|
||||||
|
repo_root="."
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--target)
|
||||||
|
target="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--base)
|
||||||
|
base_ref="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--repo-root)
|
||||||
|
repo_root="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "error: unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
echo "error: --target is required" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$target" == -* || "$base_ref" == -* ]]; then
|
||||||
|
echo "error: target and base refs cannot begin with '-'" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(git -C "$repo_root" rev-parse --show-toplevel)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
target_kind="ref"
|
||||||
|
pr_number=""
|
||||||
|
base_name=""
|
||||||
|
head_ref=""
|
||||||
|
|
||||||
|
if [[ "$target" =~ ^[0-9]+$ ]]; then
|
||||||
|
target_kind="pr"
|
||||||
|
pr_number="${BASH_REMATCH[0]}"
|
||||||
|
elif [[ "$target" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)/?$ ]]; then
|
||||||
|
target_kind="pr"
|
||||||
|
pr_number="${BASH_REMATCH[3]}"
|
||||||
|
url_repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
||||||
|
current_repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
|
||||||
|
if [[ "${url_repo,,}" != "${current_repo,,}" ]]; then
|
||||||
|
echo "error: PR URL targets $url_repo but this checkout is $current_repo" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolve_target_ref() {
|
||||||
|
local ref="$1"
|
||||||
|
local resolved=""
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/heads/${ref}"; then
|
||||||
|
git rev-parse --verify "refs/heads/${ref}^{commit}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ref" == refs/* || "$ref" == *"~"* || "$ref" == *"^"* ]] &&
|
||||||
|
resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
|
||||||
|
printf '%s\n' "$resolved"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
|
||||||
|
if git fetch origin "$ref" >/dev/null 2>&1; then
|
||||||
|
git rev-parse --verify 'FETCH_HEAD^{commit}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
|
||||||
|
printf '%s\n' "$resolved"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "error: cannot resolve ref: $ref" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_base_ref() {
|
||||||
|
local ref="$1"
|
||||||
|
local resolved=""
|
||||||
|
|
||||||
|
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
|
||||||
|
if git fetch origin "$ref" >/dev/null 2>&1; then
|
||||||
|
git rev-parse --verify 'FETCH_HEAD^{commit}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
|
||||||
|
printf '%s\n' "$resolved"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "error: cannot resolve base ref: $ref" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$target_kind" == "pr" ]]; then
|
||||||
|
repo_slug="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
|
||||||
|
pr_data="$(gh pr view "$pr_number" --repo "$repo_slug" \
|
||||||
|
--json baseRefName,headRefOid \
|
||||||
|
--jq '"\(.baseRefName) \(.headRefOid)"')"
|
||||||
|
read -r base_name reported_head_sha <<<"$pr_data"
|
||||||
|
|
||||||
|
head_ref="refs/pr-visual-review/pull/${pr_number}/head"
|
||||||
|
git fetch origin "+pull/${pr_number}/head:${head_ref}"
|
||||||
|
git fetch origin "+refs/heads/${base_name}:refs/remotes/origin/${base_name}"
|
||||||
|
|
||||||
|
after_sha="$(git rev-parse --verify "${head_ref}^{commit}")"
|
||||||
|
if [[ "$after_sha" != "$reported_head_sha" ]]; then
|
||||||
|
latest_reported_head="$(gh pr view "$pr_number" --repo "$repo_slug" \
|
||||||
|
--json headRefOid --jq .headRefOid)"
|
||||||
|
if [[ "$after_sha" != "$latest_reported_head" ]]; then
|
||||||
|
echo "error: PR head changed while resolving; rerun for a consistent target" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
base_sha="$(git rev-parse --verify "refs/remotes/origin/${base_name}^{commit}")"
|
||||||
|
else
|
||||||
|
after_sha="$(resolve_target_ref "$target")"
|
||||||
|
base_name="${base_ref:-main}"
|
||||||
|
base_sha="$(resolve_base_ref "$base_name")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
before_sha="$(git merge-base "$base_sha" "$after_sha")"
|
||||||
|
if [[ -z "$before_sha" ]]; then
|
||||||
|
echo "error: no merge-base found between $base_name and $target" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
session_dir="$(mktemp -d /tmp/bitchat-ui-review.XXXXXX)"
|
||||||
|
worktree_path="${session_dir}/worktree"
|
||||||
|
artifact_dir="${session_dir}/artifacts"
|
||||||
|
mkdir -p "$artifact_dir"
|
||||||
|
|
||||||
|
git worktree add --detach "$worktree_path" "$before_sha"
|
||||||
|
|
||||||
|
if [[ -f "${repo_root}/local.properties" && ! -e "${worktree_path}/local.properties" ]]; then
|
||||||
|
ln -s "${repo_root}/local.properties" "${worktree_path}/local.properties"
|
||||||
|
fi
|
||||||
|
|
||||||
|
session_env="${session_dir}/session.env"
|
||||||
|
{
|
||||||
|
printf 'SESSION_DIR=%q\n' "$session_dir"
|
||||||
|
printf 'WORKTREE_PATH=%q\n' "$worktree_path"
|
||||||
|
printf 'ARTIFACT_DIR=%q\n' "$artifact_dir"
|
||||||
|
printf 'SOURCE_REPO_ROOT=%q\n' "$repo_root"
|
||||||
|
printf 'TARGET_KIND=%q\n' "$target_kind"
|
||||||
|
printf 'TARGET_INPUT=%q\n' "$target"
|
||||||
|
printf 'BASE_NAME=%q\n' "$base_name"
|
||||||
|
printf 'BEFORE_SHA=%q\n' "$before_sha"
|
||||||
|
printf 'AFTER_SHA=%q\n' "$after_sha"
|
||||||
|
printf 'PR_NUMBER=%q\n' "$pr_number"
|
||||||
|
} > "$session_env"
|
||||||
|
|
||||||
|
printf 'SESSION_DIR=%s\n' "$session_dir"
|
||||||
|
printf 'WORKTREE_PATH=%s\n' "$worktree_path"
|
||||||
|
printf 'ARTIFACT_DIR=%s\n' "$artifact_dir"
|
||||||
|
printf 'BEFORE_SHA=%s\n' "$before_sha"
|
||||||
|
printf 'AFTER_SHA=%s\n' "$after_sha"
|
||||||
|
printf 'TARGET_KIND=%s\n' "$target_kind"
|
||||||
|
if [[ -n "$pr_number" ]]; then
|
||||||
|
printf 'PR_NUMBER=%s\n' "$pr_number"
|
||||||
|
fi
|
||||||
|
printf 'SESSION_ENV=%s\n' "$session_env"
|
||||||
217
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py
Executable file
@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upload PR screenshots through gh api without modifying a source branch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def gh_api(
|
||||||
|
endpoint: str,
|
||||||
|
*,
|
||||||
|
method: str = "GET",
|
||||||
|
payload: dict[str, object] | None = None,
|
||||||
|
allow_not_found: bool = False,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
command = ["gh", "api"]
|
||||||
|
if method != "GET":
|
||||||
|
command.extend(["-X", method])
|
||||||
|
command.append(endpoint)
|
||||||
|
input_text = None
|
||||||
|
if payload is not None:
|
||||||
|
command.extend(["--input", "-"])
|
||||||
|
input_text = json.dumps(payload)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
input=input_text,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
if allow_not_found and "HTTP 404" in result.stderr:
|
||||||
|
return None
|
||||||
|
raise RuntimeError(result.stderr.strip() or f"gh api failed for {endpoint}")
|
||||||
|
if not result.stdout.strip():
|
||||||
|
return {}
|
||||||
|
parsed = json.loads(result.stdout)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise RuntimeError(f"unexpected gh api response for {endpoint}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def validate_files(paths: list[Path]) -> list[tuple[Path, str, int]]:
|
||||||
|
if not paths:
|
||||||
|
raise ValueError("at least one image file is required")
|
||||||
|
files: list[tuple[Path, str, int]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for path in paths:
|
||||||
|
resolved = path.resolve()
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise ValueError(f"file not found: {path}")
|
||||||
|
name = resolved.name
|
||||||
|
if name in seen:
|
||||||
|
raise ValueError(f"duplicate basename would collide in upload: {name}")
|
||||||
|
seen.add(name)
|
||||||
|
if resolved.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
||||||
|
raise ValueError(f"unsupported image type: {name}")
|
||||||
|
files.append((resolved, name, resolved.stat().st_size))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def image_markdown(files: list[dict[str, str]]) -> str:
|
||||||
|
sections: list[str] = []
|
||||||
|
for index in range(0, len(files), 2):
|
||||||
|
pair = files[index : index + 2]
|
||||||
|
labels = " | ".join(item["name"] for item in pair)
|
||||||
|
separators = " | ".join("---" for _ in pair)
|
||||||
|
images = " | ".join(
|
||||||
|
f'![{item["name"]}]({item["url"]})' for item in pair
|
||||||
|
)
|
||||||
|
sections.append(f"| {labels} |\n| {separators} |\n| {images} |")
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--repo", required=True, help="OWNER/REPO")
|
||||||
|
parser.add_argument("--pr", required=True, type=int)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("files", nargs="+", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not REPO_RE.fullmatch(args.repo):
|
||||||
|
raise ValueError("--repo must use OWNER/REPO")
|
||||||
|
if args.pr <= 0:
|
||||||
|
raise ValueError("--pr must be a positive integer")
|
||||||
|
files = validate_files(args.files)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "dry-run",
|
||||||
|
"repo": args.repo,
|
||||||
|
"pr": args.pr,
|
||||||
|
"ref": f"refs/uploads/issues/{args.pr}",
|
||||||
|
"files": [
|
||||||
|
{"name": name, "size": size} for _, name, size in files
|
||||||
|
],
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
api_prefix = f"repos/{args.repo}"
|
||||||
|
ref_path = f"uploads/issues/{args.pr}"
|
||||||
|
ref_response = gh_api(
|
||||||
|
f"{api_prefix}/git/ref/{ref_path}",
|
||||||
|
allow_not_found=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_sha = ""
|
||||||
|
base_tree_sha = ""
|
||||||
|
if ref_response is not None:
|
||||||
|
ref_object = ref_response.get("object")
|
||||||
|
if not isinstance(ref_object, dict) or not isinstance(ref_object.get("sha"), str):
|
||||||
|
raise RuntimeError("existing upload ref response did not contain a commit SHA")
|
||||||
|
parent_sha = str(ref_object["sha"])
|
||||||
|
parent_commit = gh_api(f"{api_prefix}/git/commits/{parent_sha}")
|
||||||
|
tree = parent_commit.get("tree") if parent_commit else None
|
||||||
|
if not isinstance(tree, dict) or not isinstance(tree.get("sha"), str):
|
||||||
|
raise RuntimeError("existing upload commit did not contain a tree SHA")
|
||||||
|
base_tree_sha = str(tree["sha"])
|
||||||
|
|
||||||
|
entries: list[dict[str, str]] = []
|
||||||
|
for path, name, _ in files:
|
||||||
|
content = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
|
blob = gh_api(
|
||||||
|
f"{api_prefix}/git/blobs",
|
||||||
|
method="POST",
|
||||||
|
payload={"content": content, "encoding": "base64"},
|
||||||
|
)
|
||||||
|
blob_sha = blob.get("sha") if blob else None
|
||||||
|
if not isinstance(blob_sha, str):
|
||||||
|
raise RuntimeError(f"blob upload did not return a SHA for {name}")
|
||||||
|
entries.append(
|
||||||
|
{"path": name, "mode": "100644", "type": "blob", "sha": blob_sha}
|
||||||
|
)
|
||||||
|
|
||||||
|
tree_payload: dict[str, object] = {"tree": entries}
|
||||||
|
if base_tree_sha:
|
||||||
|
tree_payload["base_tree"] = base_tree_sha
|
||||||
|
tree_response = gh_api(
|
||||||
|
f"{api_prefix}/git/trees",
|
||||||
|
method="POST",
|
||||||
|
payload=tree_payload,
|
||||||
|
)
|
||||||
|
tree_sha = tree_response.get("sha") if tree_response else None
|
||||||
|
if not isinstance(tree_sha, str):
|
||||||
|
raise RuntimeError("tree creation did not return a SHA")
|
||||||
|
|
||||||
|
commit_response = gh_api(
|
||||||
|
f"{api_prefix}/git/commits",
|
||||||
|
method="POST",
|
||||||
|
payload={
|
||||||
|
"message": f"Add visual comparison screenshots for PR #{args.pr}",
|
||||||
|
"tree": tree_sha,
|
||||||
|
"parents": [parent_sha] if parent_sha else [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
commit_sha = commit_response.get("sha") if commit_response else None
|
||||||
|
if not isinstance(commit_sha, str):
|
||||||
|
raise RuntimeError("commit creation did not return a SHA")
|
||||||
|
|
||||||
|
if parent_sha:
|
||||||
|
gh_api(
|
||||||
|
f"{api_prefix}/git/refs/{ref_path}",
|
||||||
|
method="PATCH",
|
||||||
|
payload={"sha": commit_sha, "force": False},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
gh_api(
|
||||||
|
f"{api_prefix}/git/refs",
|
||||||
|
method="POST",
|
||||||
|
payload={"ref": f"refs/{ref_path}", "sha": commit_sha},
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded: list[dict[str, str]] = []
|
||||||
|
for _, name, _ in files:
|
||||||
|
url = f"https://github.com/{args.repo}/blob/{commit_sha}/{quote(name)}?raw=true"
|
||||||
|
uploaded.append({"name": name, "url": url})
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"repo": args.repo,
|
||||||
|
"pr": args.pr,
|
||||||
|
"ref": f"refs/{ref_path}",
|
||||||
|
"sha": commit_sha,
|
||||||
|
"files": uploaded,
|
||||||
|
"markdown": image_markdown(uploaded),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
168
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py
Executable file
@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a visual-review capture manifest and its PNG evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def relative_file(root: Path, value: object, field: str) -> Path:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
fail(f"{field} must be a non-empty relative path")
|
||||||
|
candidate = Path(value)
|
||||||
|
if candidate.is_absolute() or ".." in candidate.parts:
|
||||||
|
fail(f"{field} must stay relative to the artifact directory: {value!r}")
|
||||||
|
resolved = (root / candidate).resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root.resolve())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{field} escapes the artifact directory: {value!r}") from exc
|
||||||
|
if not resolved.is_file():
|
||||||
|
fail(f"{field} does not exist: {value}")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def png_dimensions(path: Path) -> tuple[int, int]:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
header = handle.read(24)
|
||||||
|
if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
|
||||||
|
fail(f"not a valid PNG with an IHDR header: {path.name}")
|
||||||
|
width, height = struct.unpack(">II", header[16:24])
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
fail(f"invalid PNG dimensions in {path.name}: {width}x{height}")
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def require_string(data: dict[str, object], key: str) -> str:
|
||||||
|
value = data.get(key)
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
fail(f"{key} must be a non-empty string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("manifest", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manifest_path = args.manifest.resolve()
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
fail(f"manifest not found: {manifest_path}")
|
||||||
|
root = manifest_path.parent
|
||||||
|
|
||||||
|
with manifest_path.open(encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
fail("manifest root must be an object")
|
||||||
|
|
||||||
|
require_string(data, "target")
|
||||||
|
before_sha = require_string(data, "before_sha")
|
||||||
|
after_sha = require_string(data, "after_sha")
|
||||||
|
if not SHA_RE.fullmatch(before_sha) or not SHA_RE.fullmatch(after_sha):
|
||||||
|
fail("before_sha and after_sha must be lowercase 40-character Git SHAs")
|
||||||
|
if before_sha == after_sha:
|
||||||
|
fail("before_sha and after_sha must differ")
|
||||||
|
|
||||||
|
environment = data.get("environment")
|
||||||
|
if not isinstance(environment, dict):
|
||||||
|
fail("environment must be an object")
|
||||||
|
for key in ("android_release", "android_sdk", "security_patch", "resolution_px"):
|
||||||
|
require_string(environment, key)
|
||||||
|
density = environment.get("default_density_dpi")
|
||||||
|
if not isinstance(density, int) or density <= 0:
|
||||||
|
fail("environment.default_density_dpi must be a positive integer")
|
||||||
|
|
||||||
|
captures = data.get("captures")
|
||||||
|
if not isinstance(captures, list) or not captures:
|
||||||
|
fail("captures must contain at least one before/after pair")
|
||||||
|
|
||||||
|
used_paths: set[Path] = set()
|
||||||
|
pair_summaries: list[dict[str, object]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
for index, item in enumerate(captures):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
fail(f"captures[{index}] must be an object")
|
||||||
|
capture_id = require_string(item, "id")
|
||||||
|
if capture_id in seen_ids:
|
||||||
|
fail(f"duplicate capture id: {capture_id}")
|
||||||
|
seen_ids.add(capture_id)
|
||||||
|
require_string(item, "surface")
|
||||||
|
require_string(item, "state")
|
||||||
|
require_string(item, "expected_change")
|
||||||
|
|
||||||
|
before_path = relative_file(root, item.get("before"), f"captures[{index}].before")
|
||||||
|
after_path = relative_file(root, item.get("after"), f"captures[{index}].after")
|
||||||
|
for path in (before_path, after_path):
|
||||||
|
if path in used_paths:
|
||||||
|
fail(f"screenshot reused by multiple manifest entries: {path.name}")
|
||||||
|
used_paths.add(path)
|
||||||
|
|
||||||
|
before_size = png_dimensions(before_path)
|
||||||
|
after_size = png_dimensions(after_path)
|
||||||
|
if before_size != after_size:
|
||||||
|
fail(
|
||||||
|
f"{capture_id} dimensions differ: before={before_size[0]}x{before_size[1]}, "
|
||||||
|
f"after={after_size[0]}x{after_size[1]}"
|
||||||
|
)
|
||||||
|
pair_summaries.append(
|
||||||
|
{
|
||||||
|
"id": capture_id,
|
||||||
|
"width": before_size[0],
|
||||||
|
"height": before_size[1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
extras = data.get("extras", [])
|
||||||
|
if not isinstance(extras, list):
|
||||||
|
fail("extras must be an array")
|
||||||
|
for index, item in enumerate(extras):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
fail(f"extras[{index}] must be an object")
|
||||||
|
extra_id = require_string(item, "id")
|
||||||
|
if extra_id in seen_ids:
|
||||||
|
fail(f"duplicate capture/extra id: {extra_id}")
|
||||||
|
seen_ids.add(extra_id)
|
||||||
|
require_string(item, "role")
|
||||||
|
path = relative_file(root, item.get("file"), f"extras[{index}].file")
|
||||||
|
if path in used_paths:
|
||||||
|
fail(f"screenshot reused by multiple manifest entries: {path.name}")
|
||||||
|
used_paths.add(path)
|
||||||
|
png_dimensions(path)
|
||||||
|
|
||||||
|
limitations = data.get("limitations", [])
|
||||||
|
if not isinstance(limitations, list) or not all(
|
||||||
|
isinstance(item, str) and item.strip() for item in limitations
|
||||||
|
):
|
||||||
|
fail("limitations must be an array of non-empty strings")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"status": "ok",
|
||||||
|
"pairs": len(captures),
|
||||||
|
"extras": len(extras),
|
||||||
|
"png_files": len(used_paths),
|
||||||
|
"dimensions": pair_summaries,
|
||||||
|
}
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
224
.agents/skills/mesh-lab/SKILL.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
name: mesh-lab
|
||||||
|
description: Run, diagnose, and extend bitchat Android Mesh Lab physical-device tests. Use this skill whenever work capable of changing physical peer behavior touches mesh discovery or routing, BLE or Wi-Fi transport, Noise/crypto/identity, foreground-service or power behavior, public or private messaging, file/media transfer, protocol packets, or fragmentation; whenever a user asks for physical-device validation, ADB test hooks, hardware regression reproduction, or a new Mesh Lab scenario; and before claiming that such changes work on real devices, even if the user does not name Mesh Lab explicitly. Do not use it for docs-only, unit-test-only, or pure UI changes that cannot affect mesh or service behavior.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mesh Lab
|
||||||
|
|
||||||
|
Use the repository's debug-only ADB harness to validate mesh behavior on physical
|
||||||
|
devices. Treat it as a development integration test, not as the privacy-checked
|
||||||
|
release gate and not as proof about a release APK.
|
||||||
|
|
||||||
|
## Establish current ground truth
|
||||||
|
|
||||||
|
Run from the repository root.
|
||||||
|
|
||||||
|
Before choosing commands or changing a scenario:
|
||||||
|
|
||||||
|
1. Read the Mesh Lab section of `AGENTS.md`.
|
||||||
|
2. Read the "mesh lab" appendix in `docs/release-gate-runbook.md`.
|
||||||
|
3. Use `python3 tools/release_gate/mesh_lab.py --help` and the relevant
|
||||||
|
subcommand help.
|
||||||
|
4. Inspect the selected scenario function in `tools/release_gate/mesh_lab.py`;
|
||||||
|
its current CLI and assertions are authoritative if documentation has drifted.
|
||||||
|
5. Inspect
|
||||||
|
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt` before
|
||||||
|
using an ad-hoc command, diagnosing hook behavior, or extending coverage.
|
||||||
|
|
||||||
|
Do not infer a physical pass from unit tests, compilation, old evidence, or a
|
||||||
|
successful local send call.
|
||||||
|
|
||||||
|
## Decide the physical coverage
|
||||||
|
|
||||||
|
Inspect the change or requested behavior first. Select the smallest scenario set
|
||||||
|
that exercises the affected physical contract, expanding to `all` for broad,
|
||||||
|
cross-cutting, or release-sensitive changes.
|
||||||
|
|
||||||
|
| Affected behavior | Start with |
|
||||||
|
|---|---|
|
||||||
|
| Discovery, connection management, routing, foreground-service lifecycle | `broadcast`, `dm`, `session_recovery` |
|
||||||
|
| Background, doze, or power-duty-cycle behavior | Existing setup keeps devices awake and foregrounded; add a focused workflow or use the full release gate |
|
||||||
|
| Wi-Fi Aware or transport-selection behavior | Existing setup enables BLE and does not pin Wi-Fi; add a transport-specific control/assertion or use the full release gate |
|
||||||
|
| Noise, crypto, authenticated peer state, identity persistence | `dm`, `file_private`, `session_recovery`, `identity_reset` |
|
||||||
|
| Public messaging or message delivery | `broadcast`, then `dm` if shared routing changed |
|
||||||
|
| File/media encoding, transfer, fragmentation, admission limits | `file`, `file_private`, `file_oversize` |
|
||||||
|
| Packet parsing, bridge/routing, TTL, or protocol changes | `raw`, plus a receiving scenario such as `broadcast` or `dm` |
|
||||||
|
| Broad mesh or transport refactor | `all` |
|
||||||
|
| UI-only work with no service, state, or delivery effect | Usually no Mesh Lab run; explain why |
|
||||||
|
|
||||||
|
When the selected scenarios do not exercise the new contract, add a focused
|
||||||
|
scenario instead of treating unrelated green tests as coverage.
|
||||||
|
|
||||||
|
## Protect devices and evidence
|
||||||
|
|
||||||
|
Mesh Lab setup is destructive to the app's local data. It force-stops the app,
|
||||||
|
clears package data, regenerates identity, cycles Bluetooth, grants permissions,
|
||||||
|
and changes wake/lock-screen timeout settings without restoring them.
|
||||||
|
|
||||||
|
- Use only designated disposable lab app data and deterministic test content.
|
||||||
|
- Use two authorized physical Android BLE devices on API 26 or newer. Emulators
|
||||||
|
do not exercise the required BLE mesh behavior.
|
||||||
|
- Before `setup`, `identity_reset`, or `all`, confirm that the selected devices
|
||||||
|
may have bitchat app data cleared. `identity_reset` clears device B even when
|
||||||
|
setup was skipped. If the user has not already established authorization, ask.
|
||||||
|
- Never attempt to defeat a secure lock screen. Ask the operator to unlock it.
|
||||||
|
- Keep every device unlocked, awake, foregrounded, and preferably charging.
|
||||||
|
- Treat ADB selectors as ephemeral secrets. Do not put serials, device names,
|
||||||
|
peer IDs, addresses, fingerprints, local home paths, or raw logcat in commits,
|
||||||
|
pull requests, issues, or published artifacts.
|
||||||
|
- Write raw evidence under `/tmp`, keep it local, and never commit it. Failure
|
||||||
|
evidence can include unsanitized logcat and lab identifiers.
|
||||||
|
- Use debug APKs only. The exported test-hook receiver intentionally has no
|
||||||
|
production security boundary and must never be moved into `src/main`.
|
||||||
|
- Use an authorized, controlled lab area. After setup, inspect peer state
|
||||||
|
locally and stop if an unexpected peer is present before sending broadcasts,
|
||||||
|
files, or raw packets.
|
||||||
|
- On a non-dedicated device, record the prior Bluetooth, stay-awake, screen
|
||||||
|
timeout, and lock-screen-disabled settings locally. Restore only those
|
||||||
|
recorded values after the run, or tell the operator exactly what remains
|
||||||
|
changed.
|
||||||
|
|
||||||
|
If the hardware, operator confirmation, or prerequisites are unavailable,
|
||||||
|
report the physical result as `blocked (not run)` and provide the exact handoff
|
||||||
|
command. Never soften this to "pass" or "probably works."
|
||||||
|
|
||||||
|
## Run a two-phone batch
|
||||||
|
|
||||||
|
Preflight the environment without copying device selectors into durable output:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 --version
|
||||||
|
adb devices
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up the disposable pair:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/release_gate/mesh_lab.py setup \
|
||||||
|
--serial-a "$MESH_SERIAL_A" \
|
||||||
|
--serial-b "$MESH_SERIAL_B" \
|
||||||
|
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and pass the current-tree debug APK during normal use. If setup
|
||||||
|
intentionally omits `--apk`, first verify on both devices that the installed
|
||||||
|
package is debuggable via `run-as` and that its package dump contains
|
||||||
|
`TestHookReceiver`; do this before any command that clears data.
|
||||||
|
|
||||||
|
Inspect `peers` on both devices after setup. Continue only when every discovered
|
||||||
|
participant belongs to the controlled lab.
|
||||||
|
|
||||||
|
Run either the selected scenario or the full suite. Run this entire block in one
|
||||||
|
shell invocation so the temporary-directory variable cannot disappear between
|
||||||
|
agent shell calls. Abort the block if the directory is empty or missing before
|
||||||
|
passing it to `--out`; otherwise an empty path can put private evidence in the
|
||||||
|
repository. Replace `dm` with
|
||||||
|
`all` only when full-suite data clearing has been authorized.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MESH_EVIDENCE_DIR="$(mktemp -d /tmp/meshlab-evidence.XXXXXX)"
|
||||||
|
if [ -z "$MESH_EVIDENCE_DIR" ] || [ ! -d "$MESH_EVIDENCE_DIR" ]; then
|
||||||
|
echo "mktemp failed; aborting so evidence cannot land in the repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod 700 "$MESH_EVIDENCE_DIR"
|
||||||
|
|
||||||
|
python3 tools/release_gate/mesh_lab.py scenario dm \
|
||||||
|
--serial-a "$MESH_SERIAL_A" \
|
||||||
|
--serial-b "$MESH_SERIAL_B" \
|
||||||
|
--out "$MESH_EVIDENCE_DIR"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rerun `setup` before a fresh scenario batch when prior churn, stale identities,
|
||||||
|
or zombie GATT links could contaminate the result.
|
||||||
|
|
||||||
|
Keep the private evidence only as long as the active investigation needs it.
|
||||||
|
Do not delete failure evidence that the user still needs; when it is no longer
|
||||||
|
needed, remove it or move it to an explicitly approved protected location.
|
||||||
|
|
||||||
|
## Interpret results precisely
|
||||||
|
|
||||||
|
- `dm`, `broadcast`, `file`, and `file_private` include receiver-side assertions.
|
||||||
|
- `file` and `file_private` currently validate a 1 KB deterministic fixture and
|
||||||
|
SHA-256 integrity, not sustained or boundary-sized transfer performance.
|
||||||
|
- `file_oversize` validates sender rejection and receiver absence for a 512 KB
|
||||||
|
broadcast, not the exact 256/257-fragment boundary.
|
||||||
|
- `raw` proves that the local transport bridge accepted the injected packet. It
|
||||||
|
does not prove that another device received or accepted it.
|
||||||
|
- `session_recovery` proves recoverability after process death, but may fall
|
||||||
|
back to an explicit handshake; it does not prove a fully automatic recovery.
|
||||||
|
- `identity_reset` proves new-identity recovery after `pm clear`; stale state for
|
||||||
|
the old identity is diagnostic evidence rather than a purge assertion.
|
||||||
|
- The current CLI drives exactly two phones. It does not validate a three-hop
|
||||||
|
topology, transport-specific Wi-Fi Aware behavior, permission denial, doze,
|
||||||
|
endurance, resource bounds, transfer cancellation, release builds, or
|
||||||
|
cross-client compatibility.
|
||||||
|
- For ad-hoc commands, `status: ok` can mean the command completed without
|
||||||
|
satisfying the requested state. Inspect fields such as `reached_min_peers`,
|
||||||
|
`direct`, `established`, or `cancelled`.
|
||||||
|
- `all` runs scenarios sequentially on evolving device state. In the current
|
||||||
|
runner it writes combined evidence only, and a failed sub-scenario can abort
|
||||||
|
aggregation without clean structured evidence. Run selected scenarios
|
||||||
|
individually first when durable per-scenario evidence matters, then use
|
||||||
|
`all` as broader regression coverage.
|
||||||
|
|
||||||
|
A scenario exits zero on pass and non-zero on failure. On failure, preserve the
|
||||||
|
local evidence, inspect its error first, then use state dumps and filtered logcat:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/release_gate/mesh_lab.py cmd \
|
||||||
|
--serial "$MESH_SERIAL_A" state
|
||||||
|
|
||||||
|
adb -s "$MESH_SERIAL_A" logcat -d -t 200 -s \
|
||||||
|
TestHook MessageHandler FragmentManager BitchatFilePacket
|
||||||
|
```
|
||||||
|
|
||||||
|
Common first checks are screen/foreground state, mutual discovery, direct-peer
|
||||||
|
state, Noise session state, and stale Bluetooth connections. Rerun `setup` only
|
||||||
|
after preserving useful diagnostics.
|
||||||
|
|
||||||
|
The generic `cmd --extra` wrapper does not encode every Android extra type
|
||||||
|
correctly: Boolean `enabled` and integer `min_peers`/`ttl` are notable cases.
|
||||||
|
Use a direct `adb shell am broadcast` with `--ez` or `--ei`, after reading the
|
||||||
|
driver, when exact types matter. Use the top-level `--timeout-ms 30000` option
|
||||||
|
for command timeouts; never pass `--extra timeout_ms=...`, because it duplicates
|
||||||
|
the runner's `timeout_ms` keyword and fails before dispatch.
|
||||||
|
|
||||||
|
## Add a physical-device scenario
|
||||||
|
|
||||||
|
Prefer extending `tools/release_gate/mesh_lab.py` with existing hook commands.
|
||||||
|
Add or change an Android hook only when the public mesh API cannot express the
|
||||||
|
required action or observation.
|
||||||
|
|
||||||
|
Design the scenario around an observable remote contract:
|
||||||
|
|
||||||
|
1. Generate a unique token or deterministic fixture so stale state cannot pass.
|
||||||
|
2. Start the receiver wait before sending.
|
||||||
|
3. Assert remote sender identity, content, session state, digest, or expected
|
||||||
|
absence—not merely that the sender accepted a call.
|
||||||
|
4. Use bounded timeouts and return structured JSON evidence.
|
||||||
|
5. For negative tests, assert both the expected sender error and that the
|
||||||
|
receiver did not observe the artifact.
|
||||||
|
6. Keep hook code and manifest registration under `src/debug`.
|
||||||
|
7. Update the runbook scenario table and troubleshooting guidance.
|
||||||
|
8. Run the new scenario individually, then run relevant neighboring scenarios
|
||||||
|
or `all` to detect state contamination.
|
||||||
|
|
||||||
|
Do not add test-only branches to production mesh code merely to make a scenario
|
||||||
|
easy to drive.
|
||||||
|
|
||||||
|
## Report the outcome
|
||||||
|
|
||||||
|
End with a compact physical-test report:
|
||||||
|
|
||||||
|
- Change or contract tested
|
||||||
|
- Device topology: logical roles only, such as phone A to phone B
|
||||||
|
- Build and scenario names
|
||||||
|
- Result: `pass`, `fail`, or `blocked (not run)`
|
||||||
|
- Local evidence directory, clearly marked private and uncommitted
|
||||||
|
- On failure: the exact violated invariant and the next diagnostic
|
||||||
|
- Coverage limits and any scenario fallback that weakens the claim
|
||||||
|
|
||||||
|
Keep device selectors and raw evidence out of the report, commit, and pull
|
||||||
|
request.
|
||||||
41
.agents/skills/mesh-lab/evals/evals.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "mesh-lab",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "I changed Noise session recovery and authenticated peer-state handling. Validate it on real Android devices before we merge. I have not told you whether any disposable phones are connected.",
|
||||||
|
"expected_output": "The agent inspects the current Mesh Lab implementation, selects the Noise and churn scenarios, checks hardware and destructive-setup authorization, and reports blocked rather than inventing a pass when devices are unavailable.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Selects dm, file_private, session_recovery, and identity_reset, with any omission explicitly tied to the inspected change.",
|
||||||
|
"Recognizes that setup clears app data and requires disposable lab devices.",
|
||||||
|
"Does not run setup, identity_reset, or all without destructive authorization and reports blocked (not run) when hardware is unavailable.",
|
||||||
|
"Keeps device selectors and raw evidence out of durable artifacts."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "The broadcast fragment cap changed. Two unlocked disposable phones are attached over ADB. Run the relevant Mesh Lab coverage and give me a PR-safe summary.",
|
||||||
|
"expected_output": "The agent recognizes that the stock fixtures do not prove the exact fragment boundary, adds focused below/at/above-cap receiver-side coverage when the changed contract requires it, runs relevant existing file scenarios, saves private evidence in a protected temporary directory, and produces a sanitized summary.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses the debug APK and the repository Mesh Lab runner.",
|
||||||
|
"Runs file and file_oversize, and runs file_private only if the inspected cap is shared with private transfers.",
|
||||||
|
"Adds or requires below/at/above-cap receiver-side assertions instead of treating the stock 1 KB and 512 KB fixtures as exact-boundary proof.",
|
||||||
|
"Stores unsanitized evidence in a unique mode-0700 temporary directory and does not expose ADB selectors."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "I added a new routed acknowledgement packet. Existing mesh_lab scenarios do not prove that the second phone receives and processes it. Add appropriate physical-device coverage.",
|
||||||
|
"expected_output": "The agent extends the host scenario and only the minimum necessary debug hook, asserting a unique remote observation with bounded timeouts and structured evidence while keeping test code out of production sources.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses a unique receiver-side acknowledgement observation rather than treating raw_send success as end-to-end proof.",
|
||||||
|
"Starts the receiver wait before send and uses bounded timeouts with structured JSON evidence.",
|
||||||
|
"Keeps any hook and manifest changes under app/src/debug and updates scenario registration and documentation.",
|
||||||
|
"Handles unsupported device types or topologies explicitly rather than assuming parity."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gradle
|
||||||
|
.reproducible-build
|
||||||
|
**/build
|
||||||
|
local.properties
|
||||||
|
tools/arti-build/.arti-source
|
||||||
145
.github/workflows/android-build.yml
vendored
@ -1,29 +1,142 @@
|
|||||||
name: Android Build
|
name: Android CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main, develop]
|
||||||
- '**'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches: [main, develop]
|
||||||
- '**'
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
SETUP_JAVA_VERSION: 21.0.11+10.0.LTS
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
verify:
|
||||||
runs-on: ubuntu-latest
|
name: Test and lint
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up pinned JDK
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
distribution: temurin
|
||||||
distribution: 'temurin'
|
java-version: ${{ env.SETUP_JAVA_VERSION }}
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Set up and validate Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Verify native library inputs
|
||||||
run: ./gradlew build
|
run: tools/arti-build/verify-checksums.sh
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
- name: Run lint
|
||||||
|
run: ./gradlew lintDebug
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: test-reports
|
||||||
|
path: |
|
||||||
|
**/build/test-results/
|
||||||
|
**/build/reports/tests/
|
||||||
|
|
||||||
|
- name: Upload lint results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: lint-results
|
||||||
|
path: "**/build/reports/lint-results-*.html"
|
||||||
|
|
||||||
|
build-debug:
|
||||||
|
name: Build debug APK
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: verify
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
|
||||||
|
- name: Set up pinned JDK
|
||||||
|
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.SETUP_JAVA_VERSION }}
|
||||||
|
|
||||||
|
- name: Set up and validate Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
|
||||||
|
- name: Build debug APK
|
||||||
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
- name: Upload debug APK
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: Debug-apk
|
||||||
|
path: app/build/outputs/apk/debug/*.apk
|
||||||
|
|
||||||
|
reproducible-build:
|
||||||
|
name: Reproducible release build ${{ matrix.replica }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: verify
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
replica: [a, b]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
|
||||||
|
- name: Set up pinned JDK
|
||||||
|
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.SETUP_JAVA_VERSION }}
|
||||||
|
|
||||||
|
- name: Set up and validate Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
with:
|
||||||
|
cache-disabled: true
|
||||||
|
|
||||||
|
- name: Build canonical unsigned release
|
||||||
|
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
|
||||||
|
|
||||||
|
- name: Upload build replica
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: reproducible-release-${{ matrix.replica }}
|
||||||
|
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
compare-reproducible-builds:
|
||||||
|
name: Compare release bytes
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: reproducible-build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout verification script
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
|
||||||
|
- name: Download replica A
|
||||||
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
name: reproducible-release-a
|
||||||
|
path: ${{ runner.temp }}/release-a
|
||||||
|
|
||||||
|
- name: Download replica B
|
||||||
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
name: reproducible-release-b
|
||||||
|
path: ${{ runner.temp }}/release-b
|
||||||
|
|
||||||
|
- name: Compare every canonical byte
|
||||||
|
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"
|
||||||
|
|||||||
40
.github/workflows/fetch-georelays.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
name: Fetch GeoRelays Data
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 0'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-relay-data:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Fetch GeoRelays
|
||||||
|
run: |
|
||||||
|
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
|
mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv
|
||||||
|
|
||||||
|
- name: Check for changes
|
||||||
|
id: git-check
|
||||||
|
run: |
|
||||||
|
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
if: steps.git-check.outputs.changes == 'true'
|
||||||
|
run: |
|
||||||
|
git config --local user.email "action@github.com"
|
||||||
|
git config --local user.name "GitHub Action"
|
||||||
|
git add app/src/main/assets/nostr_relays.csv
|
||||||
|
git commit -m "Automated update of relay data - $(date -u)"
|
||||||
|
git push
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
173
.github/workflows/release.yml
vendored
@ -2,99 +2,118 @@ name: Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Existing vX.Y.Z tag to build and attest
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*' # Triggers for tags like v1.0.0
|
- "v*.*.*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
name: Build unsigned release ${{ matrix.replica }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
replica: [a, b]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Validate release tag
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up JDK
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: 17
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
|
||||||
uses: gradle/gradle-build-action@v2
|
|
||||||
with:
|
|
||||||
gradle-version: wrapper
|
|
||||||
|
|
||||||
- name: Cache Gradle files
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
gradle-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Grant execute permission for Gradlew
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
- name: Build APK
|
|
||||||
run: ./gradlew assembleRelease --no-daemon --stacktrace
|
|
||||||
|
|
||||||
- name: List APK files
|
|
||||||
run: |
|
run: |
|
||||||
echo "APK files built:"
|
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||||
find app/build/outputs/apk/release -name "*.apk" -type f
|
echo "error: release tag must look like vX.Y.Z" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Rename APK
|
- name: Checkout tagged source
|
||||||
run: |
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
|
|
||||||
|
|
||||||
- name: DEBUG
|
|
||||||
run: |
|
|
||||||
set -x
|
|
||||||
|
|
||||||
pwd
|
|
||||||
|
|
||||||
ls -all
|
|
||||||
|
|
||||||
cd app/build/outputs/
|
|
||||||
ls -all
|
|
||||||
tree
|
|
||||||
|
|
||||||
# Optional: Sign APK (requires secrets)
|
|
||||||
# - name: Sign APK
|
|
||||||
# uses: r0adkll/sign-android-release@v1
|
|
||||||
# with:
|
|
||||||
# releaseDirectory: app/build/outputs/apk/release
|
|
||||||
# signingKeyBase64: ${{ secrets.SIGNING_KEY }}
|
|
||||||
# alias: ${{ secrets.ALIAS }}
|
|
||||||
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
|
|
||||||
# keyPassword: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Upload APK as artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: bitchat-release-apk-${{ github.ref_name }}
|
ref: ${{ env.RELEASE_TAG }}
|
||||||
path: app/build/outputs/apk/release/*.apk
|
|
||||||
|
- name: Verify workflow provenance ref
|
||||||
|
run: |
|
||||||
|
checked_out_commit="$(git rev-parse HEAD)"
|
||||||
|
if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then
|
||||||
|
echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate the Gradle wrapper
|
||||||
|
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||||
|
with:
|
||||||
|
cache-disabled: true
|
||||||
|
|
||||||
|
- name: Build canonical unsigned APKs and AAB
|
||||||
|
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
|
||||||
|
|
||||||
|
- name: Upload unsigned replica
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: unsigned-release-${{ matrix.replica }}
|
||||||
|
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
release:
|
compare:
|
||||||
|
name: Verify reproducibility
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
permissions:
|
||||||
|
attestations: write
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download APK artifact
|
- name: Checkout verification script
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||||
with:
|
with:
|
||||||
name: bitchat-release-apk-${{ github.ref_name }}
|
ref: ${{ env.RELEASE_TAG }}
|
||||||
path: .
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Download replica A
|
||||||
uses: softprops/action-gh-release@v2
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
with:
|
with:
|
||||||
files: bitchat.apk
|
name: unsigned-release-a
|
||||||
name: Release ${{ github.ref_name }}
|
path: ${{ runner.temp }}/release-a
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
- name: Download replica B
|
||||||
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
|
with:
|
||||||
|
name: unsigned-release-b
|
||||||
|
path: ${{ runner.temp }}/release-b
|
||||||
|
|
||||||
|
- name: Compare every canonical byte
|
||||||
|
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"
|
||||||
|
|
||||||
|
- name: Prepare public attestation subjects
|
||||||
|
run: |
|
||||||
|
mkdir "$RUNNER_TEMP/attestation-subjects"
|
||||||
|
cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \
|
||||||
|
"$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json"
|
||||||
|
cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \
|
||||||
|
"$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned"
|
||||||
|
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \
|
||||||
|
"$RUNNER_TEMP/attestation-subjects/"
|
||||||
|
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.aab \
|
||||||
|
"$RUNNER_TEMP/attestation-subjects/"
|
||||||
|
|
||||||
|
- name: Attest verified unsigned release
|
||||||
|
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||||
|
with:
|
||||||
|
subject-path: ${{ runner.temp }}/attestation-subjects/*
|
||||||
|
|
||||||
|
- name: Upload verified unsigned release
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: verified-unsigned-release
|
||||||
|
path: ${{ runner.temp }}/release-a/*
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
21
.gitignore
vendored
@ -7,11 +7,13 @@ build/
|
|||||||
!*/build/intermediates/
|
!*/build/intermediates/
|
||||||
local.properties
|
local.properties
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
captures/
|
captures/
|
||||||
.externalNativeBuild/
|
.externalNativeBuild/
|
||||||
debug_keystore/
|
debug_keystore/
|
||||||
*.keystore
|
*.keystore
|
||||||
debug.keystore
|
debug.keystore
|
||||||
|
app/release
|
||||||
|
|
||||||
# Gradle
|
# Gradle
|
||||||
/build/
|
/build/
|
||||||
@ -39,15 +41,24 @@ dependency-reduced-pom.xml
|
|||||||
# Linters
|
# Linters
|
||||||
.lint/
|
.lint/
|
||||||
|
|
||||||
|
# Python test tooling
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
release-gate-results/
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
*.log
|
*.log
|
||||||
.cxx/
|
.cxx/
|
||||||
*build/
|
# Gradle/Android build directories (but not tools/arti-build/)
|
||||||
|
**/build/
|
||||||
|
!tools/arti-build/
|
||||||
out/
|
out/
|
||||||
gen/
|
gen/
|
||||||
*~
|
*~
|
||||||
*.swp
|
*.swp
|
||||||
*.lock
|
*.lock
|
||||||
|
!tools/arti-build/Cargo.lock
|
||||||
|
.goosehints
|
||||||
|
|
||||||
# Google services
|
# Google services
|
||||||
google-services.json
|
google-services.json
|
||||||
@ -55,3 +66,11 @@ google-services.json
|
|||||||
# Keystore files
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
|
||||||
|
# Arti build artifacts (cloned repo and Rust build cache)
|
||||||
|
tools/arti-build/.arti-source/
|
||||||
|
tools/arti-build/target/
|
||||||
|
.reproducible-build/
|
||||||
|
|
||||||
|
# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
|
||||||
|
*.hprof
|
||||||
|
|||||||
1
.java-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
21.0.11
|
||||||
127
AGENTS.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Privacy & External Disclosure (Mandatory)
|
||||||
|
|
||||||
|
Protect the developer's privacy without exception. Treat all data from the
|
||||||
|
developer's computer, accounts, devices, workspace, and communications as
|
||||||
|
private unless the developer explicitly authorizes sharing a specific item
|
||||||
|
with a specific destination.
|
||||||
|
|
||||||
|
- Never expose, publish, transmit, upload, paste, commit, or otherwise disclose
|
||||||
|
personally identifying or potentially identifying information. This includes
|
||||||
|
names, usernames, email addresses, account handles, home or workplace details,
|
||||||
|
local or absolute paths, drive or volume names, hostnames, device names and
|
||||||
|
serials, IP or MAC addresses, network names, peer IDs, messages, contacts,
|
||||||
|
tokens, keys, credentials, logs, metadata, and unique environment details.
|
||||||
|
- Never post any repository, workspace, device, account, conversation, log, or
|
||||||
|
developer data to GitHub, issue trackers, pull requests, comments, gists,
|
||||||
|
paste sites, analytics services, AI services, remote APIs, chat systems, or
|
||||||
|
any other external destination without the developer's explicit permission
|
||||||
|
for that exact data and destination. A general request to work on the
|
||||||
|
repository is not permission to disclose data.
|
||||||
|
- Keep local filesystem layout and computer information private. Do not include
|
||||||
|
absolute paths, usernames, home-directory names, mounted drives, shell
|
||||||
|
prompts, environment variables, installed-software inventories, hardware
|
||||||
|
details, or similar machine fingerprints in commits, patches, documentation,
|
||||||
|
screenshots, test fixtures, examples, issue text, PR text, or comments.
|
||||||
|
- Before any authorized external action, inspect and sanitize the exact content
|
||||||
|
being sent. Use repository-relative paths and neutral placeholders; remove
|
||||||
|
hidden metadata and redact unrelated or identifying content. When safe
|
||||||
|
sanitization cannot be guaranteed, do not send the data and ask the developer
|
||||||
|
how to proceed.
|
||||||
|
- Use synthetic, non-identifying test data only. Never use real messages,
|
||||||
|
contacts, account data, device identifiers, network information, keys, or
|
||||||
|
production logs in tests, examples, screenshots, fixtures, or bug reports.
|
||||||
|
- For geohash, GPS, map, location, proximity, or geofencing work, use only
|
||||||
|
clearly synthetic coordinates and geohashes that cannot reveal the
|
||||||
|
developer's location or routines. Never request, read, derive, record, use,
|
||||||
|
display, transmit, or retain live or historical location data from any
|
||||||
|
physical phone, watch, emulator image, computer, browser, account, or other
|
||||||
|
device connected to or accessible from the developer's computer. Never infer
|
||||||
|
location from IP addresses, networks, photos, logs, timestamps, nearby peers,
|
||||||
|
device metadata, or test output. Treat all location and movement data as
|
||||||
|
highly sensitive personal information and keep it out of commits, tests,
|
||||||
|
fixtures, screenshots, documentation, issues, pull requests, and external
|
||||||
|
services without exception.
|
||||||
|
- Keep command output, raw logs, crash dumps, screenshots, recordings, build
|
||||||
|
artifacts, and Mesh Lab evidence local. Review any derived summary for
|
||||||
|
identifying details before sharing it, even when external sharing has been
|
||||||
|
authorized.
|
||||||
|
- Do not weaken these protections for convenience, debugging, automation, or
|
||||||
|
collaboration. If another instruction conflicts with this section, stop and
|
||||||
|
obtain explicit developer direction before disclosing anything.
|
||||||
|
|
||||||
|
## Project Structure & Architecture
|
||||||
|
|
||||||
|
`app/` is the Kotlin/Compose phone client; its main packages cover UI, services,
|
||||||
|
BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media.
|
||||||
|
`wear/` is the Wear OS client. Module resources live in `src/main/`
|
||||||
|
and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in
|
||||||
|
`tools/`.
|
||||||
|
|
||||||
|
`app/` is the source of truth for shared mesh/protocol code.
|
||||||
|
`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list
|
||||||
|
in `wear/build.gradle.kts`. Extend that list; never copy shared
|
||||||
|
Kotlin into `wear/src/` or edit generated `build/` content.
|
||||||
|
|
||||||
|
## Build, Test & Development Commands
|
||||||
|
|
||||||
|
Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew :app:assembleDebug :wear:assembleDebug
|
||||||
|
./gradlew testDebugUnitTest lintDebug
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
|
./gradlew clientRewriteContractTest
|
||||||
|
tools/arti-build/verify-checksums.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs `testDebugUnitTest lintDebug`; instrumented tests require a device.
|
||||||
|
Follow `docs/reproducible-builds.md` and `docs/maintainer-release-guide.md` for
|
||||||
|
dependency and release work.
|
||||||
|
|
||||||
|
## Coding Style & Naming
|
||||||
|
|
||||||
|
Use official Kotlin style with four-space indentation. Classes and Composables
|
||||||
|
use `PascalCase`; functions and properties use `camelCase`; constants use
|
||||||
|
`UPPER_SNAKE_CASE`. Hoist Compose state, expose immutable `StateFlow`, use
|
||||||
|
structured coroutines and suspend I/O, and never block the main thread.
|
||||||
|
|
||||||
|
Protocol and security changes must remain fail-closed and cross-client
|
||||||
|
compatible. Update the relevant specification and golden-vector tests.
|
||||||
|
|
||||||
|
## Testing & Physical Mesh Lab
|
||||||
|
|
||||||
|
Tests use JUnit 4, Robolectric, Mockito, and coroutine test utilities. Name files
|
||||||
|
`*Test.kt` by observable behavior. Avoid arbitrary sleeps, public
|
||||||
|
relays, live user data, and nondeterministic completion. See
|
||||||
|
`docs/testing-conventions.md`.
|
||||||
|
|
||||||
|
Changes affecting discovery, routing, transports, Noise/crypto, identity,
|
||||||
|
foreground-service power, messaging, transfers, packets, or fragmentation
|
||||||
|
require Mesh Lab validation on physical devices. Debug-only hooks live in
|
||||||
|
`app/src/debug/` and `wear/src/debug/`; never move them into release sources.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/release_gate/mesh_lab.py setup \
|
||||||
|
--serial-a <device-a> --serial-b <device-b> --apk <debug-apk>
|
||||||
|
python3 tools/release_gate/mesh_lab.py scenario all \
|
||||||
|
--serial-a <device-a> --serial-b <device-b> --out /tmp/mesh-evidence
|
||||||
|
```
|
||||||
|
|
||||||
|
For phone-to-watch interop, replace `--serial-b` with `--serial-watch` and add
|
||||||
|
`--watch-apk`. Keep devices unlocked and awake. Follow the Mesh Lab appendix in
|
||||||
|
`docs/release-gate-runbook.md`; raw evidence and logcat must remain local.
|
||||||
|
|
||||||
|
## Commits, Pull Requests & Privacy
|
||||||
|
|
||||||
|
Use short imperative subjects (`Fix stale peer lifecycle cleanup`) or scoped
|
||||||
|
Conventional Commit subjects (`fix(wifi-aware): ...`). PRs must explain risk,
|
||||||
|
link the issue, report tests, and include before/after screenshots for visible
|
||||||
|
phone or watch changes. Do not publish raw device logs.
|
||||||
|
|
||||||
|
Use `gh` for GitHub operations. Never override Git author or committer identity.
|
||||||
|
The mandatory privacy rules above apply to all Git and GitHub activity. Treat a
|
||||||
|
request to create a commit or pull request as permission only for the sanitized
|
||||||
|
repository changes and description needed for that action, never for local or
|
||||||
|
personal metadata. Never commit keystores or secrets.
|
||||||
171
CHANGELOG.md
@ -1,171 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
## [0.7.2] - 2025-07-20
|
|
||||||
### Fixed
|
|
||||||
- fix: battery optimization screen content scrollable with fixed buttons
|
|
||||||
|
|
||||||
## [0.7.1] - 2025-07-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
-feat(battery): add battery optimization management for background reliability
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered
|
|
||||||
- fix: update sidebar text to use string resources
|
|
||||||
- fix(chat): cursor location and enhance message input with slash command styling
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- refactor: remove context attribute at ChatViewModel.kt
|
|
||||||
- Refactor: Migrate MainViewModel to use StateFlow
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Use HorizontalDivider instead of deprecated Divider
|
|
||||||
- Use contentPadding instead of padding so items remain fully visible
|
|
||||||
|
|
||||||
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [0.7]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Location services check during app startup with educational UI
|
|
||||||
- Message text selection functionality in chat interface
|
|
||||||
- Enhanced RSSI tracking and unread message indicators
|
|
||||||
- Major Bluetooth connection architecture refactoring with dedicated managers
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Critical**: Android-iOS message fragmentation compatibility issues
|
|
||||||
- Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging
|
|
||||||
- Ensures Android can properly communicate with iOS devices
|
|
||||||
- DirectMessage notifications and text copying functionality
|
|
||||||
- Smart routing optimizations (no relay loops, targeted delivery)
|
|
||||||
- Build system compilation issues and null pointer exceptions
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01)
|
|
||||||
- Optimized BLE scan intervals for better battery performance
|
|
||||||
- Reduced excessive logging output
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Cross-platform compatibility with iOS and Rust implementations
|
|
||||||
- Connection stability through architectural improvements
|
|
||||||
- Battery performance via scan duty cycling
|
|
||||||
- User onboarding with location services education
|
|
||||||
|
|
||||||
## [0.6]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Channel password management with `/pass` command for channel owners
|
|
||||||
- Monochrome/themed launcher icon for Android 12+ dynamic theming support
|
|
||||||
- Unit tests package with initial testing infrastructure
|
|
||||||
- Production build optimization with code minification and shrinking
|
|
||||||
- Native back gesture/button handling for all app views
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Favorite peer functionality completely restored and improved
|
|
||||||
- Enhanced favorite system with fallback mechanism for peers without key exchange
|
|
||||||
- Fixed UI state updates for favorite stars in both header and sidebar
|
|
||||||
- Improved favorite persistence across app sessions
|
|
||||||
- `/w` command now displays user nicknames instead of peer IDs
|
|
||||||
- Button styling and layout improvements across the app
|
|
||||||
- Enhanced back button positioning and styling
|
|
||||||
- Improved private chat and channel header button layouts
|
|
||||||
- Fixed button padding and alignment issues
|
|
||||||
- Color scheme consistency updates
|
|
||||||
- Updated orange color throughout the app to match iOS version
|
|
||||||
- Consistent color usage for private messages and UI elements
|
|
||||||
- App startup reliability improvements
|
|
||||||
- Better initialization sequence handling
|
|
||||||
- Fixed null pointer exceptions during startup
|
|
||||||
- Enhanced error handling and logging
|
|
||||||
- Input field styling and behavior improvements
|
|
||||||
- Sidebar user interaction enhancements
|
|
||||||
- Permission explanation screen layout fixes with proper vertical padding
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Updated GitHub organization references in project files
|
|
||||||
- Improved README documentation with updated clone URLs
|
|
||||||
- Enhanced logging throughout the application for better debugging
|
|
||||||
|
|
||||||
## [0.5.1] - 2025-07-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Bluetooth startup check with user prompt to enable Bluetooth if disabled
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Improved Bluetooth initialization reliability on first app launch
|
|
||||||
|
|
||||||
## [0.5] - 2025-07-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- New user onboarding screen with permission explanations
|
|
||||||
- Educational content explaining why each permission is required
|
|
||||||
- Privacy assurance messaging (no tracking, no servers, local-only data)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Comprehensive permission validation - ensures all required permissions are granted
|
|
||||||
- Proper Bluetooth stack initialization on first app load
|
|
||||||
- Eliminated need for manual app restart after installation
|
|
||||||
- Enhanced permission request coordination and error handling
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Improved first-time user experience with guided setup flow
|
|
||||||
|
|
||||||
## [0.4] - 2025-07-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Push notifications for direct messages
|
|
||||||
- Enhanced notification system with proper click handling and grouping
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
- Direct message (DM) view with better user interface
|
|
||||||
- Enhanced private messaging experience
|
|
||||||
|
|
||||||
### Known Issues
|
|
||||||
- Favorite peer functionality currently broken
|
|
||||||
|
|
||||||
## [0.3] - 2025-07-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Battery-aware scanning policies for improved power management
|
|
||||||
- Dynamic scan behavior based on device battery state
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Android-to-Android Bluetooth Low Energy connections
|
|
||||||
- Peer discovery reliability between Android devices
|
|
||||||
- Connection stability improvements
|
|
||||||
|
|
||||||
## [0.2] - 2025-07-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial Android implementation of bitchat protocol
|
|
||||||
- Bluetooth Low Energy mesh networking
|
|
||||||
- End-to-end encryption for private messages
|
|
||||||
- Channel-based messaging with password protection
|
|
||||||
- Store-and-forward message delivery
|
|
||||||
- IRC-style commands (/msg, /join, /clear, etc.)
|
|
||||||
- RSSI-based signal quality indicators
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Various Bluetooth handling improvements
|
|
||||||
- User interface refinements
|
|
||||||
- Connection reliability enhancements
|
|
||||||
|
|
||||||
## [0.1] - 2025-07-08
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial release of bitchat Android client
|
|
||||||
- Basic mesh networking functionality
|
|
||||||
- Core messaging features
|
|
||||||
- Protocol compatibility with iOS bitchat client
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
|
|
||||||
[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
|
|
||||||
[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
|
|
||||||
[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
|
|
||||||
[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
|
|
||||||
[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
|
|
||||||
[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1
|
|
||||||
687
LICENSE.md
@ -1,21 +1,674 @@
|
|||||||
MIT License
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
Copyright (c) 2025
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Preamble
|
||||||
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
|
The GNU General Public License is a free, copyleft license for
|
||||||
copies or substantial portions of the Software.
|
software and other kinds of works.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
The licenses for most software and other practical works are designed
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
share and change all versions of a program--to make sure it remains free
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
GNU General Public License for most of our software; it applies also to
|
||||||
SOFTWARE.
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|||||||
@ -8,8 +8,13 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
**WE DO NOT COLLECT ANY INFORMATION.**
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||||
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
|
||||||
|
- **Hybrid Functionality** - bitchat offers two modes of communication:
|
||||||
|
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
|
||||||
|
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
|
||||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **Open source** - You can verify these claims by reading our code
|
||||||
|
|
||||||
@ -66,7 +71,8 @@ When you join a password-protected room:
|
|||||||
|
|
||||||
bitchat **never**:
|
bitchat **never**:
|
||||||
- Collects personal information
|
- Collects personal information
|
||||||
- Tracks your location
|
- Collects location history
|
||||||
|
- Transmits any data to us (the developers)
|
||||||
- Stores data on servers
|
- Stores data on servers
|
||||||
- Shares data with third parties
|
- Shares data with third parties
|
||||||
- Uses analytics or telemetry
|
- Uses analytics or telemetry
|
||||||
@ -89,13 +95,24 @@ You have complete control:
|
|||||||
- **No Account**: Nothing to delete from servers because there are none
|
- **No Account**: Nothing to delete from servers because there are none
|
||||||
- **Portability**: Your data never leaves your device unless you export it
|
- **Portability**: Your data never leaves your device unless you export it
|
||||||
|
|
||||||
## Bluetooth & Permissions
|
## Location Data & Permissions
|
||||||
|
|
||||||
bitchat requires Bluetooth permission to function:
|
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
|
||||||
- Used only for peer-to-peer communication
|
|
||||||
- No location data is accessed or stored
|
### 1. Bluetooth Low Energy (BLE) Scanning
|
||||||
- Bluetooth is not used for tracking
|
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
|
||||||
- You can revoke this permission at any time in system settings
|
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
|
||||||
|
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
|
||||||
|
|
||||||
|
### 2. Geohash Chat Functionality
|
||||||
|
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
|
||||||
|
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
|
||||||
|
- **Privacy protection:**
|
||||||
|
- Your precise GPS coordinates are **never** sent to any server or peer.
|
||||||
|
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
|
||||||
|
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
|
||||||
|
|
||||||
|
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
|
||||||
|
|
||||||
## Children's Privacy
|
## Children's Privacy
|
||||||
|
|
||||||
|
|||||||
338
README.md
@ -1,25 +1,29 @@
|
|||||||
<p align="center">
|
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
|
||||||
<img src="https://github.com/user-attachments/assets/188c42f8-d249-4a72-b27a-e2b4f10a00a8" alt="Bitchat Android Logo" width="480">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> [!WARNING]
|
## bitchat for Android
|
||||||
> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress.
|
|
||||||
|
|
||||||
# bitchat for Android
|
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
|
||||||
|
|
||||||
A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers - just pure encrypted communication.
|
This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication.
|
||||||
|
|
||||||
This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication.
|
[bitchat.free](http://bitchat.free)
|
||||||
|
|
||||||
## Install bitchat
|
[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases)
|
||||||
|
|
||||||
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
|
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](https://play.google.com/store/apps/details?id=com.bitchat.droid)
|
||||||
|
|
||||||
**Instructions:**
|
## See it in action
|
||||||
|
|
||||||
1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
|
<table>
|
||||||
2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**.
|
<tr>
|
||||||
3. **Install:** Open the downloaded `.apk` file to begin the installation.
|
<th>Offline mesh conversation</th>
|
||||||
|
<th>Geohash globe picker</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="docs/screenshots/readme-mesh-chat.png" alt="Active four-peer Bitchat mesh conversation with an image, voice messages, and text messages" width="360"/></td>
|
||||||
|
<td><img src="docs/screenshots/readme-geohash-globe.png" alt="Bitchat geohash location picker showing the whole Earth and geohash grid" width="360"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@ -27,277 +31,75 @@ This project is released into the public domain. See the [LICENSE](LICENSE.md) f
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
|
- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
|
||||||
- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays
|
||||||
- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
|
- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
|
||||||
- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
|
- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
|
||||||
- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
|
||||||
- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices
|
||||||
- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
|
- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
|
||||||
- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
|
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
|
||||||
- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
|
- **Tor Support**: Built-in Tor (Arti) for private internet connectivity
|
||||||
- **✅ Modern Android UI**: Jetpack Compose with Material Design 3
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version
|
- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS
|
||||||
- **✅ Battery Optimization**: Adaptive scanning and power management
|
|
||||||
|
|
||||||
## Android Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Android Studio**: Arctic Fox (2020.3.1) or newer
|
|
||||||
- **Android SDK**: API level 26 (Android 8.0) or higher
|
|
||||||
- **Kotlin**: 1.8.0 or newer
|
|
||||||
- **Gradle**: 7.0 or newer
|
|
||||||
|
|
||||||
### Build Instructions
|
|
||||||
|
|
||||||
1. **Clone the repository:**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/permissionlesstech/bitchat-android.git
|
|
||||||
cd bitchat-android
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Open in Android Studio:**
|
|
||||||
```bash
|
|
||||||
# Open Android Studio and select "Open an Existing Project"
|
|
||||||
# Navigate to the bitchat-android directory
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Build the project:**
|
|
||||||
```bash
|
|
||||||
./gradlew build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Install on device:**
|
|
||||||
```bash
|
|
||||||
./gradlew installDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Build
|
|
||||||
|
|
||||||
For development builds with debugging enabled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew assembleDebug
|
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Build
|
|
||||||
|
|
||||||
For production releases:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew assembleRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android-Specific Requirements
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
The app requires the following permissions (automatically requested):
|
|
||||||
|
|
||||||
- **Bluetooth**: Core BLE functionality
|
|
||||||
- **Location**: Required for BLE scanning on Android
|
|
||||||
- **Notifications**: Message alerts and background updates
|
|
||||||
|
|
||||||
### Hardware Requirements
|
|
||||||
|
|
||||||
- **Bluetooth LE (BLE)**: Required for mesh networking
|
|
||||||
- **Android 8.0+**: API level 26 minimum
|
|
||||||
- **RAM**: 2GB recommended for optimal performance
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Commands
|
|
||||||
|
|
||||||
- `/j #channel` - Join or create a channel
|
|
||||||
- `/m @name message` - Send a private message
|
|
||||||
- `/w` - List online users
|
|
||||||
- `/channels` - Show all discovered channels
|
|
||||||
- `/block @name` - Block a peer from messaging you
|
|
||||||
- `/block` - List all blocked peers
|
|
||||||
- `/unblock @name` - Unblock a peer
|
|
||||||
- `/clear` - Clear chat messages
|
|
||||||
- `/pass [password]` - Set/change channel password (owner only)
|
|
||||||
- `/transfer @name` - Transfer channel ownership
|
|
||||||
- `/save` - Toggle message retention for channel (owner only)
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
1. **Install the app** on your Android device (requires Android 8.0+)
|
|
||||||
2. **Grant permissions** for Bluetooth and location when prompted
|
|
||||||
3. **Launch bitchat** - it will auto-start mesh networking
|
|
||||||
4. **Set your nickname** or use the auto-generated one
|
|
||||||
5. **Connect automatically** to nearby iOS and Android bitchat users
|
|
||||||
6. **Join a channel** with `/j #general` or start chatting in public
|
|
||||||
7. **Messages relay** through the mesh network to reach distant peers
|
|
||||||
|
|
||||||
### Android UI Features
|
|
||||||
|
|
||||||
- **Jetpack Compose UI**: Modern Material Design 3 interface
|
|
||||||
- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS
|
|
||||||
- **Haptic Feedback**: Vibrations for interactions and notifications
|
|
||||||
- **Adaptive Layout**: Optimized for various Android screen sizes
|
|
||||||
- **Message Status**: Real-time delivery and read receipts
|
|
||||||
- **RSSI Indicators**: Signal strength colors for each peer
|
|
||||||
|
|
||||||
### Channel Features
|
|
||||||
|
|
||||||
- **Password Protection**: Channel owners can set passwords with `/pass`
|
|
||||||
- **Message Retention**: Owners can enable mandatory message saving with `/save`
|
|
||||||
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
|
|
||||||
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
|
|
||||||
|
|
||||||
## Security & Privacy
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
|
|
||||||
- **Channel Messages**: Argon2id password derivation + AES-256-GCM
|
|
||||||
- **Digital Signatures**: Ed25519 for message authenticity
|
|
||||||
- **Forward Secrecy**: New key pairs generated each session
|
|
||||||
|
|
||||||
### Privacy Features
|
|
||||||
- **No Registration**: No accounts, emails, or phone numbers required
|
|
||||||
- **Ephemeral by Default**: Messages exist only in device memory
|
|
||||||
- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis
|
|
||||||
- **Emergency Wipe**: Triple-tap logo to instantly clear all data
|
|
||||||
- **Local-First**: Works completely offline, no servers involved
|
|
||||||
|
|
||||||
## Performance & Efficiency
|
|
||||||
|
|
||||||
### Message Compression
|
|
||||||
- **LZ4 Compression**: Automatic compression for messages >100 bytes
|
|
||||||
- **30-70% bandwidth savings** on typical text messages
|
|
||||||
- **Smart compression**: Skips already-compressed data
|
|
||||||
|
|
||||||
### Battery Optimization
|
|
||||||
- **Adaptive Power Modes**: Automatically adjusts based on battery level
|
|
||||||
- Performance mode: Full features when charging or >60% battery
|
|
||||||
- Balanced mode: Default operation (30-60% battery)
|
|
||||||
- Power saver: Reduced scanning when <30% battery
|
|
||||||
- Ultra-low power: Emergency mode when <10% battery
|
|
||||||
- **Background efficiency**: Automatic power saving when app backgrounded
|
|
||||||
- **Configurable scanning**: Duty cycle adapts to battery state
|
|
||||||
|
|
||||||
### Network Efficiency
|
|
||||||
- **Optimized Bloom filters**: Faster duplicate detection with less memory
|
|
||||||
- **Message aggregation**: Batches small messages to reduce transmissions
|
|
||||||
- **Adaptive connection limits**: Adjusts peer connections based on power mode
|
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
|
|
||||||
### Binary Protocol
|
### Bluetooth Mesh Network (Offline)
|
||||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
|
||||||
- Compact packet format with 1-byte type field
|
|
||||||
- TTL-based message routing (max 7 hops)
|
|
||||||
- Automatic fragmentation for large messages
|
|
||||||
- Message deduplication via unique IDs
|
|
||||||
|
|
||||||
### Mesh Networking
|
- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
|
||||||
- Each device acts as both client and peripheral
|
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
|
||||||
- Automatic peer discovery and connection management
|
- Compact binary packet format with fragmentation, TTL routing, and deduplication
|
||||||
- Store-and-forward for offline message delivery
|
- Adaptive duty cycling and connection limits for battery efficiency
|
||||||
- Adaptive duty cycling for battery optimization
|
- Foreground service keeps the mesh alive within Android background execution limits
|
||||||
|
|
||||||
### Android-Specific Optimizations
|
### Nostr Protocol (Internet)
|
||||||
- **Coroutine Architecture**: Asynchronous operations for mesh networking
|
|
||||||
- **Kotlin Coroutines**: Thread-safe concurrent mesh operations
|
|
||||||
- **EncryptedSharedPreferences**: Secure storage for user settings
|
|
||||||
- **Lifecycle-Aware**: Proper handling of Android app lifecycle
|
|
||||||
- **Battery Optimization**: Foreground service and adaptive scanning
|
|
||||||
|
|
||||||
## Android Technical Architecture
|
- Global reach via public relays, geohash-based location channels
|
||||||
|
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
|
||||||
|
- Ephemeral keys per geohash area
|
||||||
|
|
||||||
### Core Components
|
### Android Stack
|
||||||
|
|
||||||
1. **BitchatApplication.kt**: Application-level initialization and dependency injection
|
- Kotlin, Jetpack Compose (Material 3), MVVM
|
||||||
2. **MainActivity.kt**: Main activity handling permissions and UI hosting
|
- Coroutines and Flow for all networking and state
|
||||||
3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic
|
- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry)
|
||||||
4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles)
|
|
||||||
5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle
|
|
||||||
6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format
|
|
||||||
7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3
|
|
||||||
|
|
||||||
### Dependencies
|
## Building
|
||||||
|
|
||||||
- **Jetpack Compose**: Modern declarative UI
|
Requires Android Studio and the Android SDK (API 26+).
|
||||||
- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM)
|
|
||||||
- **Nordic BLE Library**: Reliable Bluetooth LE operations
|
|
||||||
- **Kotlin Coroutines**: Asynchronous programming
|
|
||||||
- **LZ4**: Message compression (when enabled)
|
|
||||||
- **EncryptedSharedPreferences**: Secure local storage
|
|
||||||
|
|
||||||
### Binary Protocol Compatibility
|
```bash
|
||||||
|
git clone https://github.com/permissionlesstech/bitchat-android.git
|
||||||
|
cd bitchat-android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
The Android implementation maintains 100% binary protocol compatibility with iOS:
|
Install on a connected device:
|
||||||
- **Header Format**: Identical 13-byte header structure
|
|
||||||
- **Packet Types**: Same message types and routing logic
|
|
||||||
- **Encryption**: Identical cryptographic algorithms and key exchange
|
|
||||||
- **UUIDs**: Same Bluetooth service and characteristic identifiers
|
|
||||||
- **Fragmentation**: Compatible message fragmentation for large content
|
|
||||||
|
|
||||||
## Publishing to Google Play
|
```bash
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
### Preparation
|
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
|
||||||
|
|
||||||
1. **Update version information:**
|
Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the
|
||||||
```kotlin
|
pinned Linux container. Maintainers should follow the
|
||||||
// In app/build.gradle.kts
|
[Android release guide](docs/maintainer-release-guide.md). See
|
||||||
defaultConfig {
|
[Reproducible builds](docs/reproducible-builds.md) for the build trust model
|
||||||
versionCode = 2 // Increment for each release
|
and public GitHub/Google Play verification procedures.
|
||||||
versionName = "1.1.0" // User-visible version
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a signed release build:**
|
## Testing
|
||||||
```bash
|
|
||||||
./gradlew assembleRelease
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Generate app bundle (recommended for Play Store):**
|
```bash
|
||||||
```bash
|
# Unit tests
|
||||||
./gradlew bundleRelease
|
./gradlew test
|
||||||
```
|
|
||||||
|
|
||||||
### Play Store Requirements
|
# Lint
|
||||||
|
./gradlew lint
|
||||||
|
|
||||||
- **Target API**: Latest Android API (currently 34)
|
# Instrumented tests (requires a device or emulator)
|
||||||
- **Privacy Policy**: Required for apps requesting sensitive permissions
|
./gradlew connectedAndroidTest
|
||||||
- **App Permissions**: Justify Bluetooth and location usage
|
```
|
||||||
- **Content Rating**: Complete questionnaire for age-appropriate content
|
|
||||||
|
|
||||||
### Distribution
|
Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.
|
||||||
|
|
||||||
- **Google Play Store**: Main distribution channel
|
|
||||||
- **F-Droid**: For open-source distribution
|
|
||||||
- **Direct APK**: For testing and development
|
|
||||||
|
|
||||||
## Cross-Platform Communication
|
|
||||||
|
|
||||||
This Android port enables seamless communication with the original iOS bitchat app:
|
|
||||||
|
|
||||||
- **iPhone ↔ Android**: Full bidirectional messaging
|
|
||||||
- **Mixed Groups**: iOS and Android users in same channels
|
|
||||||
- **Feature Parity**: All commands and encryption work across platforms
|
|
||||||
- **Protocol Sync**: Identical message format and routing behavior
|
|
||||||
|
|
||||||
**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Key areas for enhancement:
|
|
||||||
|
|
||||||
1. **Performance**: Battery optimization and connection reliability
|
|
||||||
2. **UI/UX**: Additional Material Design 3 features
|
|
||||||
3. **Security**: Enhanced cryptographic features
|
|
||||||
4. **Testing**: Unit and integration test coverage
|
|
||||||
5. **Documentation**: API documentation and development guides
|
|
||||||
|
|
||||||
## Support & Issues
|
|
||||||
|
|
||||||
- **Bug Reports**: [Create an issue](../../issues) with device info and logs
|
|
||||||
- **Feature Requests**: [Start a discussion](../../discussions)
|
|
||||||
- **Security Issues**: Email security concerns privately
|
|
||||||
- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat)
|
|
||||||
|
|
||||||
For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat).
|
|
||||||
|
|||||||
@ -1,20 +1,42 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.parcelize)
|
alias(libs.plugins.kotlin.parcelize)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val githubReleaseCertSha256 = providers
|
||||||
|
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
|
||||||
|
.orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
|
||||||
|
.orElse("")
|
||||||
|
val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
|
||||||
|
.replace(":", "")
|
||||||
|
.trim()
|
||||||
|
.lowercase()
|
||||||
|
require(
|
||||||
|
normalizedGithubReleaseCertSha256.isEmpty() ||
|
||||||
|
normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
|
||||||
|
) {
|
||||||
|
"BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.bitchat.android"
|
namespace = "com.bitchat.android"
|
||||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
buildToolsVersion = libs.versions.buildTools.get()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.bitchat.android"
|
applicationId = "com.bitchat.droid"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 6
|
versionCode = 38
|
||||||
versionName = "0.8"
|
versionName = "2.0.1"
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"GITHUB_RELEASE_CERT_SHA256",
|
||||||
|
"\"$normalizedGithubReleaseCertSha256\""
|
||||||
|
)
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -22,7 +44,20 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// Disables dependency metadata when building APKs.
|
||||||
|
includeInApk = false
|
||||||
|
// Disables dependency metadata when building Android App Bundles.
|
||||||
|
includeInBundle = false
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
ndk {
|
||||||
|
// Include x86_64 for emulator support during development
|
||||||
|
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||||
|
}
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
@ -30,17 +65,39 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
vcsInfo {
|
||||||
|
// BUILDINFO.json and attestations carry the verified commit
|
||||||
|
// without depending on host-specific Git/worktree paths.
|
||||||
|
include = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
// AAB for Play Store handles architecture distribution automatically
|
||||||
|
// Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks
|
||||||
|
// Works in Android Studio GUI and CLI without needing extra properties
|
||||||
|
val enableSplits = gradle.startParameter.taskNames.any { taskName ->
|
||||||
|
taskName.contains("assemble", ignoreCase = true) &&
|
||||||
|
!taskName.contains("bundle", ignoreCase = true)
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
splits {
|
||||||
|
abi {
|
||||||
|
isEnable = enableSplits
|
||||||
|
reset()
|
||||||
|
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||||
|
isUniversalApk = true // For F-Droid and fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@ -54,6 +111,20 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeCompiler {
|
||||||
|
// Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified
|
||||||
|
// class-file iteration order. Keep the normal R8 mapping, but omit that
|
||||||
|
// augmentation until its producer is deterministic across clean builds.
|
||||||
|
includeComposeMappingFile.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core Android dependencies
|
// Core Android dependencies
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@ -66,12 +137,22 @@ dependencies {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
implementation(libs.bundles.lifecycle)
|
implementation(libs.bundles.lifecycle)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
implementation(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
|
|
||||||
|
// QR
|
||||||
|
implementation(libs.zxing.core)
|
||||||
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
|
|
||||||
|
// CameraX
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.compose)
|
||||||
|
|
||||||
// Cryptography
|
// Cryptography
|
||||||
implementation(libs.bundles.cryptography)
|
implementation(libs.bundles.cryptography)
|
||||||
@ -84,16 +165,43 @@ dependencies {
|
|||||||
|
|
||||||
// Bluetooth
|
// Bluetooth
|
||||||
implementation(libs.nordic.ble)
|
implementation(libs.nordic.ble)
|
||||||
|
|
||||||
// Compression
|
// WebSocket
|
||||||
implementation(libs.lz4.java)
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
|
// WorkManager for background APK downloads
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
|
||||||
|
// HTTP Server for hotspot APK sharing
|
||||||
|
implementation(libs.nanohttpd)
|
||||||
|
|
||||||
|
// Arti (Tor in Rust) Android bridge - custom build from latest source
|
||||||
|
// Built with rustls, 16KB page size support, and onio//un service client
|
||||||
|
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
|
||||||
|
// Only included in tor flavor to reduce APK size for standard builds
|
||||||
|
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
|
||||||
|
|
||||||
|
// Google Play Services Location
|
||||||
|
implementation(libs.gms.location)
|
||||||
|
|
||||||
// Security preferences
|
// Security preferences
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
// EXIF orientation handling for images
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.bundles.testing)
|
testImplementation(libs.bundles.testing)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.bundles.compose.testing)
|
androidTestImplementation(libs.bundles.compose.testing)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).
|
||||||
|
// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403.
|
||||||
|
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
|
||||||
|
systemProperty(
|
||||||
|
"robolectric.dependency.repo.url",
|
||||||
|
"https://repo.maven.apache.org/maven2"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
464
app/gradle.lockfile
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
# This is a Gradle generated file for dependency locking.
|
||||||
|
# Manual edits can break the build and are not advised.
|
||||||
|
# This file is expected to be part of source control.
|
||||||
|
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
|
||||||
|
androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera.featurecombinationquery:featurecombinationquery:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera:camera-camera2-pipe:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
|
||||||
|
androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
|
||||||
|
androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-service:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.room:room-ktx:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.room:room-runtime:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath
|
||||||
|
androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath
|
||||||
|
androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath
|
||||||
|
androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools.analytics-library:tracker:32.3.1=androidLintTool
|
||||||
|
com.android.tools.build:aapt2-proto:9.3.1-15703166=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools.build:builder-model:9.3.1=androidLintTool
|
||||||
|
com.android.tools.build:manifest-merger:32.3.1=androidLintTool
|
||||||
|
com.android.tools.ddms:ddmlib:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.emulator:proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
com.android.tools.external.com-intellij:intellij-core:32.3.1=androidLintTool
|
||||||
|
com.android.tools.external.com-intellij:kotlin-compiler:32.3.1=androidLintTool
|
||||||
|
com.android.tools.external.org-jetbrains:uast:32.3.1=androidLintTool
|
||||||
|
com.android.tools.layoutlib:layoutlib-api:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools.lint:lint-api:32.3.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-checks:32.3.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-gradle:32.3.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-model:32.3.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint-typedef-remover:32.3.1=androidLintTool
|
||||||
|
com.android.tools.lint:lint:32.3.1=androidLintTool
|
||||||
|
com.android.tools.utp:android-device-provider-ddmlib-proto:32.3.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-device-provider-ddmlib:32.3.1=unified-test-platform-android-device-provider-ddmlib
|
||||||
|
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output
|
||||||
|
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-host-apk-installer:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer
|
||||||
|
com.android.tools.utp:android-test-plugin-host-coverage-proto:32.3.1=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-host-coverage:32.3.1=unified-test-platform-android-test-plugin-host-coverage
|
||||||
|
com.android.tools.utp:android-test-plugin-host-device-info-proto:32.3.1=unified-test-platform-android-test-plugin-host-device-info
|
||||||
|
com.android.tools.utp:android-test-plugin-host-device-info:32.3.1=unified-test-platform-android-test-plugin-host-device-info
|
||||||
|
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-host-emulator-control:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
com.android.tools.utp:android-test-plugin-host-logcat-proto:32.3.1=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-host-logcat:32.3.1=unified-test-platform-android-test-plugin-host-logcat
|
||||||
|
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:android-test-plugin-result-listener-gradle:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools.utp:gradle-work-action:32.3.1=unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools.utp:utp-common:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat
|
||||||
|
com.android.tools:annotations:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools:common:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.android.tools:dvlib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools:play-sdk-proto:32.3.1=androidLintTool
|
||||||
|
com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core
|
||||||
|
com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core
|
||||||
|
com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action
|
||||||
|
com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
|
||||||
|
com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
|
||||||
|
com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
|
||||||
|
com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.google.guava:failureaccess:1.0.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.google.guava:guava:33.4.8-jre=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core
|
||||||
|
com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.protobuf:protobuf-java:3.25.5=androidLintTool
|
||||||
|
com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platform-android-test-plugin
|
||||||
|
com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core
|
||||||
|
com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
commons-io:commons-io:2.16.1=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
commons-logging:commons-logging:1.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
io.grpc:grpc-api:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-api:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-context:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-context:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-core:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-core:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-netty:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-netty:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-protobuf-lite:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-protobuf-lite:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-protobuf:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-protobuf:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-services:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-stub:1.57.2=unified-test-platform-core
|
||||||
|
io.grpc:grpc-stub:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.grpc:grpc-util:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-buffer:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-buffer:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-codec-http2:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-codec-http2:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-codec-http:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-codec-http:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-codec-socks:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-codec-socks:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-codec:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-codec:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-common:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-handler-proxy:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-handler-proxy:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-handler:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-handler:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-resolver:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-resolver:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-transport-native-unix-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-transport-native-unix-common:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.netty:netty-transport:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
io.netty:netty-transport:4.1.93.Final=unified-test-platform-core
|
||||||
|
io.opencensus:opencensus-api:0.31.0=unified-test-platform-core
|
||||||
|
io.opencensus:opencensus-proto:0.2.0=unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
io.perfmark:perfmark-api:0.26.0=unified-test-platform-core
|
||||||
|
io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
javax.annotation:javax.annotation-api:1.3.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
|
||||||
|
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
org.codehaus.groovy:groovy:3.0.22=androidLintTool
|
||||||
|
org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core
|
||||||
|
org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control
|
||||||
|
org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
|
||||||
|
org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-client:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.1.20=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
|
||||||
|
org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
|
||||||
|
org.jetbrains.kotlin:kotlin-tooling-core:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
|
||||||
|
org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseRuntimeClasspathCopy
|
||||||
|
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
|
||||||
|
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
|
||||||
|
org.mockito.kotlin:mockito-kotlin:6.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.mockito:mockito-core:5.23.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
|
||||||
|
org.objenesis:objenesis:3.3=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.ow2.asm:asm-analysis:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-commons:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.ow2.asm:asm-commons:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.ow2.asm:asm-tree:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
|
||||||
|
org.ow2.asm:asm:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.ow2.asm:asm:9.9=androidLintTool
|
||||||
|
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
|
||||||
|
org.robolectric:annotations:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:junit:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:nativeruntime-dist-compat:1.0.17=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:nativeruntime:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:pluginapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:plugins-maven-dependency-resolver:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:resources:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:robolectric:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:sandbox:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:shadowapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:shadows-framework:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:utils-reflector:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.robolectric:utils:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
org.yaml:snakeyaml:2.4=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
|
||||||
|
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues
|
||||||
@ -243,17 +243,6 @@
|
|||||||
column="35"/>
|
column="35"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="OldTargetApi"
|
|
||||||
message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the android.os.Build.VERSION_CODES javadoc for details."
|
|
||||||
errorLine1=" targetSdk = 34"
|
|
||||||
errorLine2=" ~~~~~~~~~~~~~~">
|
|
||||||
<location
|
|
||||||
file="build.gradle.kts"
|
|
||||||
line="14"
|
|
||||||
column="9"/>
|
|
||||||
</issue>
|
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="RedundantLabel"
|
id="RedundantLabel"
|
||||||
message="Redundant label can be removed"
|
message="Redundant label can be removed"
|
||||||
|
|||||||
33
app/proguard-rules.pro
vendored
@ -5,3 +5,36 @@
|
|||||||
-keep class com.bitchat.android.crypto.** { *; }
|
-keep class com.bitchat.android.crypto.** { *; }
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
-keep class org.bouncycastle.** { *; }
|
-keep class org.bouncycastle.** { *; }
|
||||||
|
|
||||||
|
# Keep SecureIdentityStateManager from being obfuscated to prevent reflection issues
|
||||||
|
-keep class com.bitchat.android.identity.SecureIdentityStateManager {
|
||||||
|
private android.content.SharedPreferences prefs;
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep all classes that might use reflection
|
||||||
|
-keep class com.bitchat.android.favorites.** { *; }
|
||||||
|
-keep class com.bitchat.android.nostr.** { *; }
|
||||||
|
-keep class com.bitchat.android.identity.** { *; }
|
||||||
|
|
||||||
|
# Room loads generated database implementations by name and invokes their no-argument
|
||||||
|
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
|
||||||
|
# constructor, causing AndroidX Startup to crash before Application.onCreate.
|
||||||
|
-keepclassmembers class * extends androidx.room.RoomDatabase {
|
||||||
|
<init>();
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Tor implementation (always included)
|
||||||
|
-keep class com.bitchat.android.net.RealTorProvider { *; }
|
||||||
|
|
||||||
|
# Arti (Custom Tor implementation in Rust) ProGuard rules
|
||||||
|
-keep class info.guardianproject.arti.** { *; }
|
||||||
|
-keep class org.torproject.arti.** { *; }
|
||||||
|
-keepnames class org.torproject.arti.**
|
||||||
|
-dontwarn info.guardianproject.arti.**
|
||||||
|
-dontwarn org.torproject.arti.**
|
||||||
|
|
||||||
|
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
|
||||||
|
-keepclassmembers class * implements android.location.LocationListener {
|
||||||
|
public <methods>;
|
||||||
|
}
|
||||||
|
|||||||
19
app/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Debug-only ADB test hook. Drives mesh operations (scan, connect,
|
||||||
|
handshake, DMs, files, broadcast, raw packets) from host scripts.
|
||||||
|
Exported intentionally so `adb shell am broadcast` can reach it;
|
||||||
|
never shipped in release builds. -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.bitchat.android.testhook.TestHookReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
tools:ignore="ExportedReceiver">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.bitchat.droid.TEST_HOOK" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
package com.bitchat.android.testhook
|
||||||
|
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
internal data class PttTestAudioAnalysis(
|
||||||
|
val decodedSamples: Long,
|
||||||
|
val rms: Double,
|
||||||
|
val silentBlockFraction: Double,
|
||||||
|
val longestSilentBlockRun: Int,
|
||||||
|
val zeroCrossingsPerSecond: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
|
||||||
|
internal object PttTestAudioAnalyzer {
|
||||||
|
private const val BLOCK_SAMPLES = 1_024
|
||||||
|
private const val SILENT_BLOCK_RMS = 0.015
|
||||||
|
private const val SAMPLE_RATE = 16_000.0
|
||||||
|
|
||||||
|
fun analyze(file: File): PttTestAudioAnalysis {
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
var decoder: MediaCodec? = null
|
||||||
|
try {
|
||||||
|
extractor.setDataSource(file.absolutePath)
|
||||||
|
val track = (0 until extractor.trackCount).firstOrNull { index ->
|
||||||
|
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
|
||||||
|
} ?: error("received live stream has no audio track")
|
||||||
|
extractor.selectTrack(track)
|
||||||
|
val format = extractor.getTrackFormat(track)
|
||||||
|
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
|
||||||
|
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
|
||||||
|
configure(format, null, null, 0)
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
decoder = activeDecoder
|
||||||
|
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
var inputEnded = false
|
||||||
|
var outputEnded = false
|
||||||
|
var idlePolls = 0
|
||||||
|
var decodedSamples = 0L
|
||||||
|
var sumSquares = 0.0
|
||||||
|
var zeroCrossings = 0L
|
||||||
|
var previousSample: Short? = null
|
||||||
|
var blockSquares = 0.0
|
||||||
|
var blockSamples = 0
|
||||||
|
var blocks = 0
|
||||||
|
var silentBlocks = 0
|
||||||
|
var silentRun = 0
|
||||||
|
var longestSilentRun = 0
|
||||||
|
|
||||||
|
while (!outputEnded && idlePolls < 500) {
|
||||||
|
if (!inputEnded) {
|
||||||
|
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
|
||||||
|
if (inputIndex >= 0) {
|
||||||
|
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
|
||||||
|
input.clear()
|
||||||
|
val size = extractor.readSampleData(input, 0)
|
||||||
|
if (size < 0) {
|
||||||
|
activeDecoder.queueInputBuffer(
|
||||||
|
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
inputEnded = true
|
||||||
|
} else {
|
||||||
|
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
|
||||||
|
extractor.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
|
||||||
|
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
|
||||||
|
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
|
||||||
|
else -> if (outputIndex >= 0) {
|
||||||
|
idlePolls = 0
|
||||||
|
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
|
||||||
|
output.position(info.offset)
|
||||||
|
output.limit(info.offset + info.size)
|
||||||
|
output.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
val pcm = output.asShortBuffer()
|
||||||
|
while (pcm.hasRemaining()) {
|
||||||
|
val sample = pcm.get()
|
||||||
|
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
|
||||||
|
val square = normalized * normalized
|
||||||
|
sumSquares += square
|
||||||
|
blockSquares += square
|
||||||
|
decodedSamples++
|
||||||
|
blockSamples++
|
||||||
|
previousSample?.let { previous ->
|
||||||
|
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
|
||||||
|
zeroCrossings++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousSample = sample
|
||||||
|
if (blockSamples == BLOCK_SAMPLES) {
|
||||||
|
val blockRms = sqrt(blockSquares / blockSamples)
|
||||||
|
blocks++
|
||||||
|
if (blockRms < SILENT_BLOCK_RMS) {
|
||||||
|
silentBlocks++
|
||||||
|
silentRun++
|
||||||
|
longestSilentRun = maxOf(longestSilentRun, silentRun)
|
||||||
|
} else {
|
||||||
|
silentRun = 0
|
||||||
|
}
|
||||||
|
blockSquares = 0.0
|
||||||
|
blockSamples = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
activeDecoder.releaseOutputBuffer(outputIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!outputEnded) error("received live stream decoder did not finish")
|
||||||
|
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
|
||||||
|
if (blockSamples > 0) {
|
||||||
|
val blockRms = sqrt(blockSquares / blockSamples)
|
||||||
|
blocks++
|
||||||
|
if (blockRms < SILENT_BLOCK_RMS) {
|
||||||
|
silentBlocks++
|
||||||
|
silentRun++
|
||||||
|
longestSilentRun = maxOf(longestSilentRun, silentRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PttTestAudioAnalysis(
|
||||||
|
decodedSamples = decodedSamples,
|
||||||
|
rms = sqrt(sumSquares / decodedSamples),
|
||||||
|
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
|
||||||
|
longestSilentBlockRun = longestSilentRun,
|
||||||
|
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
runCatching { decoder?.stop() }
|
||||||
|
runCatching { decoder?.release() }
|
||||||
|
extractor.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,733 @@
|
|||||||
|
package com.bitchat.android.testhook
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||||
|
import com.bitchat.android.features.file.FileUtils
|
||||||
|
import com.bitchat.android.features.voice.LiveVoiceEvent
|
||||||
|
import com.bitchat.android.features.voice.LiveVoiceManager
|
||||||
|
import com.bitchat.android.features.voice.LiveVoicePreferences
|
||||||
|
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||||
|
import com.bitchat.android.features.voice.LiveVoiceTarget
|
||||||
|
import com.bitchat.android.features.voice.LiveVoiceCapture
|
||||||
|
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
|
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||||
|
import com.bitchat.android.mesh.TransferProgressManager
|
||||||
|
import com.bitchat.android.model.BitchatFilePacket
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
|
import com.bitchat.android.noise.NoiseSession
|
||||||
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
import com.bitchat.android.service.MeshForegroundService
|
||||||
|
import com.bitchat.android.service.MeshServiceHolder
|
||||||
|
import com.bitchat.android.service.TransportBridgeService
|
||||||
|
import com.bitchat.android.services.AppStateStore
|
||||||
|
import com.bitchat.android.ui.DataManager
|
||||||
|
import com.bitchat.android.ui.PrivateMediaRecipientResolver
|
||||||
|
import com.bitchat.android.util.AppConstants
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless engine behind [TestHookReceiver]. Drives the public [MeshService] API and
|
||||||
|
* observes state via [AppStateStore] flows (never touches the single-slot mesh delegate).
|
||||||
|
*/
|
||||||
|
object TestHookDriver {
|
||||||
|
|
||||||
|
private const val TAG = TestHookReceiver.TAG
|
||||||
|
|
||||||
|
private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L
|
||||||
|
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
|
||||||
|
private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L
|
||||||
|
private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L
|
||||||
|
private const val DEFAULT_FILE_TIMEOUT_MS = 180_000L
|
||||||
|
|
||||||
|
suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject {
|
||||||
|
Log.d(TAG, "execute cmd=$cmd")
|
||||||
|
val result = when (cmd) {
|
||||||
|
"ping" -> ok(cmd).put("pong", true).put("package", context.packageName)
|
||||||
|
"start" -> start(context)
|
||||||
|
"stop" -> stop(context)
|
||||||
|
"whoami" -> whoami(context)
|
||||||
|
"set_nickname" -> setNickname(context, intent.requiredString("name"))
|
||||||
|
"scan" -> scan(context, intent)
|
||||||
|
"peers" -> peers(context)
|
||||||
|
"connect" -> connect(intent.requiredString("peer"), intent)
|
||||||
|
"handshake" -> handshake(context, intent.requiredString("peer"), intent)
|
||||||
|
"session" -> session(context, intent.requiredString("peer"))
|
||||||
|
"announce" -> announce(context)
|
||||||
|
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
|
||||||
|
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
|
||||||
|
"dm_recv" -> dmRecv(context, intent)
|
||||||
|
"msg_recv" -> msgRecv(context, intent)
|
||||||
|
"favorite_set" -> favoriteSet(
|
||||||
|
context,
|
||||||
|
intent.requiredString("peer"),
|
||||||
|
intent.getBooleanExtra("enabled", true)
|
||||||
|
)
|
||||||
|
"favorite_status" -> favoriteStatus(context, intent.requiredString("peer"))
|
||||||
|
"verification_set" -> verificationSet(
|
||||||
|
context,
|
||||||
|
intent.requiredString("peer"),
|
||||||
|
intent.getBooleanExtra("enabled", true)
|
||||||
|
)
|
||||||
|
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
|
||||||
|
"file_send" -> fileSend(context, intent)
|
||||||
|
"file_recv" -> fileRecv(context, intent)
|
||||||
|
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
|
||||||
|
"ptt_send" -> pttSend(context, intent)
|
||||||
|
"ptt_recv" -> pttRecv(context, intent)
|
||||||
|
"raw_send" -> rawSend(context, intent)
|
||||||
|
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
|
||||||
|
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
|
||||||
|
"state" -> state(context)
|
||||||
|
"clear_results" -> clearResults(context)
|
||||||
|
else -> err(cmd, "unknown command: $cmd")
|
||||||
|
}
|
||||||
|
return result.put("cmd", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
private fun start(context: Context): JSONObject {
|
||||||
|
MeshForegroundService.start(context)
|
||||||
|
val mesh = mesh(context)
|
||||||
|
mesh.startServices()
|
||||||
|
return ok("start").put("peer_id", mesh.myPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop(context: Context): JSONObject {
|
||||||
|
try {
|
||||||
|
MeshServiceHolder.unifiedMeshService?.stopServices()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "stopServices failed: ${e.message}")
|
||||||
|
}
|
||||||
|
MeshForegroundService.stop(context)
|
||||||
|
return ok("stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
|
||||||
|
private fun whoami(context: Context): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
return ok("whoami")
|
||||||
|
.put("peer_id", mesh.myPeerID)
|
||||||
|
.put("identity_fingerprint", mesh.getIdentityFingerprint())
|
||||||
|
.put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex())
|
||||||
|
.put("nickname", AppStateStore.nickname.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setNickname(context: Context, name: String): JSONObject {
|
||||||
|
DataManager(context).saveNickname(name)
|
||||||
|
AppStateStore.setNickname(name)
|
||||||
|
mesh(context).sendBroadcastAnnounce()
|
||||||
|
return ok("set_nickname").put("nickname", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Discovery / connection
|
||||||
|
|
||||||
|
private suspend fun scan(context: Context, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS)
|
||||||
|
val minPeers = intent.getIntExtra("min_peers", 1)
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val found = withTimeoutOrNull(timeoutMs) {
|
||||||
|
AppStateStore.peers.first { it.size >= minPeers }
|
||||||
|
}
|
||||||
|
val peerIds = found ?: AppStateStore.peers.value
|
||||||
|
return ok("scan")
|
||||||
|
.put("reached_min_peers", found != null)
|
||||||
|
.put("peers", peerInfosJson(mesh, peerIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peers(context: Context): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-only state injection for testing peer-list consumers such as notifications.
|
||||||
|
* A blank or missing comma-separated value restores the empty state.
|
||||||
|
*/
|
||||||
|
private fun injectPeers(commaSeparatedPeers: String?): JSONObject {
|
||||||
|
val peers = commaSeparatedPeers
|
||||||
|
.orEmpty()
|
||||||
|
.split(',')
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotEmpty)
|
||||||
|
.distinct()
|
||||||
|
AppStateStore.setPeers(peers)
|
||||||
|
return ok("inject_peers").put("peers", JSONArray(peers))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connect(peerID: String, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
|
||||||
|
val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running")
|
||||||
|
val address = ble.getDeviceAddressForPeer(peerID)
|
||||||
|
?: return err("connect", "no device address known for peer $peerID (scan first)")
|
||||||
|
val accepted = ble.connectionManager.connectToAddress(address)
|
||||||
|
if (!accepted) return err("connect", "connectToAddress($address) rejected")
|
||||||
|
val direct = withTimeoutOrNull(timeoutMs) {
|
||||||
|
AppStateStore.directPeers.first { it.contains(peerID) }
|
||||||
|
}
|
||||||
|
return ok("connect")
|
||||||
|
.put("peer", peerID)
|
||||||
|
.put("address", address)
|
||||||
|
.put("direct", direct != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise
|
||||||
|
|
||||||
|
private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS)
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
if (!mesh.hasEstablishedSession(peerID)) {
|
||||||
|
mesh.initiateNoiseHandshake(peerID)
|
||||||
|
}
|
||||||
|
var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
lastState = mesh.getSessionState(peerID)
|
||||||
|
when (lastState) {
|
||||||
|
is NoiseSession.NoiseSessionState.Established -> {
|
||||||
|
return ok("handshake")
|
||||||
|
.put("peer", peerID)
|
||||||
|
.put("state", lastState.toString())
|
||||||
|
.put("fingerprint", mesh.getPeerFingerprint(peerID))
|
||||||
|
}
|
||||||
|
is NoiseSession.NoiseSessionState.Failed -> {
|
||||||
|
return err("handshake", "session failed: $lastState").put("peer", peerID)
|
||||||
|
}
|
||||||
|
else -> delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun session(context: Context, peerID: String): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
return ok("session")
|
||||||
|
.put("peer", peerID)
|
||||||
|
.put("state", mesh.getSessionState(peerID).toString())
|
||||||
|
.put("established", mesh.hasEstablishedSession(peerID))
|
||||||
|
.put("fingerprint", mesh.getPeerFingerprint(peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Messaging
|
||||||
|
|
||||||
|
private fun announce(context: Context): JSONObject {
|
||||||
|
mesh(context).sendBroadcastAnnounce()
|
||||||
|
return ok("announce")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastMsg(context: Context, content: String, channel: String?): JSONObject {
|
||||||
|
mesh(context).sendMessage(content, emptyList(), channel)
|
||||||
|
return ok("broadcast_msg").put("content", content).put("channel", channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||||
|
val id = msgID ?: "testhook-${System.currentTimeMillis()}"
|
||||||
|
mesh.sendPrivateMessage(content, peerID, nickname, id)
|
||||||
|
return ok("dm_send").put("peer", peerID).put("msg_id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||||
|
val fromPeer = intent.getStringExtra("peer")
|
||||||
|
val contains = intent.getStringExtra("contains")
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val match = withTimeoutOrNull(timeoutMs) {
|
||||||
|
AppStateStore.privateMessages.first { conversations ->
|
||||||
|
conversations.values.flatten().any { msg ->
|
||||||
|
msg.timestamp.time >= startTime &&
|
||||||
|
msg.senderPeerID != mesh.myPeerID &&
|
||||||
|
(fromPeer == null || msg.senderPeerID == fromPeer) &&
|
||||||
|
(contains == null || msg.content.contains(contains))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
|
||||||
|
val msg = match.values.flatten().first { msg ->
|
||||||
|
msg.timestamp.time >= startTime &&
|
||||||
|
msg.senderPeerID != mesh.myPeerID &&
|
||||||
|
(fromPeer == null || msg.senderPeerID == fromPeer) &&
|
||||||
|
(contains == null || msg.content.contains(contains))
|
||||||
|
}
|
||||||
|
return ok("dm_recv")
|
||||||
|
.put("from", msg.senderPeerID)
|
||||||
|
.put("sender", msg.sender)
|
||||||
|
.put("content", msg.content)
|
||||||
|
.put("msg_id", msg.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun msgRecv(context: Context, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||||
|
val contains = intent.getStringExtra("contains")
|
||||||
|
val channel = intent.getStringExtra("channel")
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
|
||||||
|
msg.timestamp.time >= startTime &&
|
||||||
|
msg.senderPeerID != mesh.myPeerID &&
|
||||||
|
(contains == null || msg.content.contains(contains)) &&
|
||||||
|
(channel == null || msg.channel == channel)
|
||||||
|
}
|
||||||
|
val found = withTimeoutOrNull(timeoutMs) {
|
||||||
|
if (channel != null) {
|
||||||
|
AppStateStore.channelMessages.first { m -> m.values.flatten().any(matches) }
|
||||||
|
.values.flatten().first(matches)
|
||||||
|
} else {
|
||||||
|
AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
|
||||||
|
}
|
||||||
|
} ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
|
||||||
|
return ok("msg_recv")
|
||||||
|
.put("from", found.senderPeerID)
|
||||||
|
.put("sender", found.sender)
|
||||||
|
.put("content", found.content)
|
||||||
|
.put("channel", found.channel)
|
||||||
|
.put("msg_id", found.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Favorite and verification state
|
||||||
|
|
||||||
|
private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val peerInfo = mesh.getPeerInfo(peerID)
|
||||||
|
?: return err("favorite_set", "peer is not known")
|
||||||
|
val noisePublicKey = peerInfo.noisePublicKey
|
||||||
|
?: return err("favorite_set", "peer Noise key is unavailable")
|
||||||
|
FavoritesPersistenceService.initialize(context)
|
||||||
|
FavoritesPersistenceService.shared.updateFavoriteStatus(
|
||||||
|
noisePublicKey = noisePublicKey,
|
||||||
|
nickname = peerInfo.nickname,
|
||||||
|
isFavorite = enabled
|
||||||
|
)
|
||||||
|
mesh.sendFavoriteNotification(peerID, enabled)
|
||||||
|
return favoriteStatus(context, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun favoriteStatus(context: Context, peerID: String): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
FavoritesPersistenceService.initialize(context)
|
||||||
|
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||||
|
?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let {
|
||||||
|
FavoritesPersistenceService.shared.getFavoriteStatus(it)
|
||||||
|
}
|
||||||
|
val isFavorite = relationship?.isFavorite == true
|
||||||
|
val theyFavoritedUs = relationship?.theyFavoritedUs == true
|
||||||
|
return ok("favorite_status")
|
||||||
|
.put("peer", peerID)
|
||||||
|
.put("is_favorite", isFavorite)
|
||||||
|
.put("they_favorited_us", theyFavoritedUs)
|
||||||
|
.put("is_mutual", isFavorite && theyFavoritedUs)
|
||||||
|
.put(
|
||||||
|
"star_state",
|
||||||
|
when {
|
||||||
|
isFavorite -> "filled"
|
||||||
|
theyFavoritedUs -> "outlined_orange"
|
||||||
|
else -> "outlined"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val fingerprint = mesh.getPeerFingerprint(peerID)
|
||||||
|
?: return err("verification_set", "peer fingerprint is unavailable")
|
||||||
|
SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled)
|
||||||
|
return verificationStatus(context, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verificationStatus(context: Context, peerID: String): JSONObject {
|
||||||
|
val fingerprint = mesh(context).getPeerFingerprint(peerID)
|
||||||
|
val verified = fingerprint != null &&
|
||||||
|
SecureIdentityStateManager(context).getVerifiedFingerprints().any {
|
||||||
|
it.equals(fingerprint, ignoreCase = true)
|
||||||
|
}
|
||||||
|
return ok("verification_status")
|
||||||
|
.put("peer", peerID)
|
||||||
|
.put("fingerprint", fingerprint ?: JSONObject.NULL)
|
||||||
|
.put("verified", verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File transfer
|
||||||
|
|
||||||
|
private suspend fun fileSend(context: Context, intent: Intent): JSONObject {
|
||||||
|
val path = intent.requiredString("path")
|
||||||
|
val peerID = intent.getStringExtra("peer")
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||||
|
val mesh = mesh(context)
|
||||||
|
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile) return err("file_send", "file not found: $path")
|
||||||
|
val content = withContext(Dispatchers.IO) { file.readBytes() }
|
||||||
|
if (content.size.toLong() > AppConstants.Media.MAX_FILE_SIZE_BYTES) {
|
||||||
|
return err("file_send", "file too large: ${content.size} > ${AppConstants.Media.MAX_FILE_SIZE_BYTES}")
|
||||||
|
}
|
||||||
|
val packet = BitchatFilePacket(
|
||||||
|
fileName = file.name,
|
||||||
|
fileSize = content.size.toLong(),
|
||||||
|
mimeType = intent.getStringExtra("mime") ?: FileUtils.getMimeTypeFromExtension(file.name),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet")
|
||||||
|
val transferId = sha256Hex(encoded)
|
||||||
|
val recipient = peerID?.let {
|
||||||
|
PrivateMediaRecipientResolver.resolve(it, mesh)
|
||||||
|
?: return err("file_send", "no active mesh route for private conversation: $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
return coroutineScope {
|
||||||
|
// Subscribe on a background dispatcher before sending so synchronous
|
||||||
|
// failure events are not missed (SharedFlow has replay=0).
|
||||||
|
val completion = async(Dispatchers.Default) {
|
||||||
|
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
val sendError = dispatchFileSend(
|
||||||
|
context,
|
||||||
|
intent,
|
||||||
|
mesh,
|
||||||
|
recipient?.meshPeerID,
|
||||||
|
packet,
|
||||||
|
transferId
|
||||||
|
)
|
||||||
|
if (sendError != null) {
|
||||||
|
completion.cancel()
|
||||||
|
return@coroutineScope sendError.put("cmd", "file_send")
|
||||||
|
}
|
||||||
|
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
|
||||||
|
?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)")
|
||||||
|
if (event.failed) {
|
||||||
|
return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)")
|
||||||
|
.put("transfer_id", transferId)
|
||||||
|
}
|
||||||
|
ok("file_send")
|
||||||
|
.put("transfer_id", transferId)
|
||||||
|
.put("sent", event.sent)
|
||||||
|
.put("total", event.total)
|
||||||
|
.put("bytes", content.size)
|
||||||
|
.put("peer", recipient?.meshPeerID)
|
||||||
|
.put("conversation", peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchFileSend(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
mesh: MeshService,
|
||||||
|
peerID: String?,
|
||||||
|
packet: BitchatFilePacket,
|
||||||
|
transferId: String
|
||||||
|
): JSONObject? {
|
||||||
|
if (peerID == null) {
|
||||||
|
mesh.sendFileBroadcast(packet)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!mesh.hasEstablishedSession(peerID)) {
|
||||||
|
val hs = handshake(context, peerID, intent)
|
||||||
|
if (hs.optString("status") != "ok") return hs
|
||||||
|
}
|
||||||
|
// Peer state (capabilities/identity) can lag session establishment;
|
||||||
|
// retry transient preparation states before giving up.
|
||||||
|
val prepDeadline = System.currentTimeMillis() + 30_000
|
||||||
|
while (true) {
|
||||||
|
when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) {
|
||||||
|
is PrivateMediaPreparation.Ready -> {
|
||||||
|
return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed")
|
||||||
|
}
|
||||||
|
PrivateMediaPreparation.AwaitingPeerState,
|
||||||
|
PrivateMediaPreparation.NeedsHandshake -> {
|
||||||
|
if (System.currentTimeMillis() >= prepDeadline) {
|
||||||
|
return err("file_send", "private media preparation stuck at: $prep")
|
||||||
|
}
|
||||||
|
if (prep == PrivateMediaPreparation.NeedsHandshake) {
|
||||||
|
mesh.initiateNoiseHandshake(peerID)
|
||||||
|
}
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
else -> return err("file_send", "private media preparation: $prep")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||||
|
val nameContains = intent.getStringExtra("name_contains")
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
val dirs = listOf(
|
||||||
|
File(context.cacheDir, "files/incoming"),
|
||||||
|
File(context.cacheDir, "images/incoming")
|
||||||
|
)
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val candidate = dirs
|
||||||
|
.flatMap { it.listFiles()?.toList() ?: emptyList() }
|
||||||
|
.filter { it.lastModified() >= startTime - 5_000 }
|
||||||
|
.filter { nameContains == null || it.name.contains(nameContains) }
|
||||||
|
.maxByOrNull { it.lastModified() }
|
||||||
|
if (candidate != null) {
|
||||||
|
val size1 = candidate.length()
|
||||||
|
delay(500)
|
||||||
|
if (candidate.length() == size1 && size1 > 0) {
|
||||||
|
return ok("file_recv")
|
||||||
|
.put("path", candidate.absolutePath)
|
||||||
|
.put("name", candidate.name)
|
||||||
|
.put("bytes", size1)
|
||||||
|
.put("sha256", withContext(Dispatchers.IO) { sha256Hex(candidate.readBytes()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
return err("file_recv", "timeout after ${timeoutMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileCancel(context: Context, transferId: String): JSONObject {
|
||||||
|
val cancelled = mesh(context).cancelFileTransfer(transferId)
|
||||||
|
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live push-to-talk
|
||||||
|
|
||||||
|
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
|
||||||
|
val requestedPeer = intent.getStringExtra("peer")
|
||||||
|
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||||
|
val mesh = mesh(context)
|
||||||
|
LiveVoicePreferences.setEnabled(context, true)
|
||||||
|
val recipient = requestedPeer?.let {
|
||||||
|
PrivateMediaRecipientResolver.resolve(it, mesh)
|
||||||
|
?: return err("ptt_send", "no active mesh route for private conversation")
|
||||||
|
}
|
||||||
|
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
|
||||||
|
val handshake = handshake(context, recipient.meshPeerID, intent)
|
||||||
|
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
|
||||||
|
}
|
||||||
|
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
|
||||||
|
val recorder = LiveVoiceCapture(
|
||||||
|
File(context.filesDir, "voicenotes/outgoing"),
|
||||||
|
target,
|
||||||
|
syntheticPcm = true
|
||||||
|
)
|
||||||
|
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
|
||||||
|
delay(durationMs)
|
||||||
|
val finalFile = recorder.stop(canceled = false)
|
||||||
|
?: return err("ptt_send", "capture did not produce a finalized note")
|
||||||
|
val captureStats = recorder.stats()
|
||||||
|
if (finalFile != pendingFile || !finalFile.isFile) {
|
||||||
|
return err("ptt_send", "finalized note is unavailable")
|
||||||
|
}
|
||||||
|
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
|
||||||
|
val packet = BitchatFilePacket(
|
||||||
|
fileName = finalFile.name,
|
||||||
|
fileSize = content.size.toLong(),
|
||||||
|
mimeType = "audio/mp4",
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
|
||||||
|
val transferId = sha256Hex(encoded)
|
||||||
|
return coroutineScope {
|
||||||
|
val completion = async(Dispatchers.Default) {
|
||||||
|
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
|
||||||
|
}
|
||||||
|
delay(50)
|
||||||
|
val sendError = dispatchFileSend(
|
||||||
|
context,
|
||||||
|
intent,
|
||||||
|
mesh,
|
||||||
|
recipient?.meshPeerID,
|
||||||
|
packet,
|
||||||
|
transferId
|
||||||
|
)
|
||||||
|
if (sendError != null) {
|
||||||
|
completion.cancel()
|
||||||
|
return@coroutineScope sendError.put("cmd", "ptt_send")
|
||||||
|
}
|
||||||
|
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
|
||||||
|
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
|
||||||
|
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
|
||||||
|
ok("ptt_send")
|
||||||
|
.put("live", true)
|
||||||
|
.put("scope", if (recipient == null) "public" else "dm")
|
||||||
|
.put("duration_ms", durationMs)
|
||||||
|
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
|
||||||
|
.put("bytes", content.size)
|
||||||
|
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
|
||||||
|
.put("encoded_frames", captureStats.encodedFrames)
|
||||||
|
.put("data_packets", captureStats.dataPackets)
|
||||||
|
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
|
||||||
|
.put("outbound_packets", captureStats.outboundPackets)
|
||||||
|
.put("delivered_packets", captureStats.deliveredPackets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
|
||||||
|
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
|
||||||
|
val fromPeer = intent.getStringExtra("peer")
|
||||||
|
val expectedScope = if (intent.getStringExtra("scope") == "public") {
|
||||||
|
LiveVoiceScope.PUBLIC_MESH
|
||||||
|
} else {
|
||||||
|
LiveVoiceScope.DIRECT_MESSAGE
|
||||||
|
}
|
||||||
|
LiveVoicePreferences.setEnabled(context, true)
|
||||||
|
var finished: LiveVoiceEvent.Finished? = null
|
||||||
|
var liveSnapshot: File? = null
|
||||||
|
val absorbed = withTimeoutOrNull(timeoutMs) {
|
||||||
|
LiveVoiceManager.getInstance(context).events.first { event ->
|
||||||
|
val matches = event.scope == expectedScope &&
|
||||||
|
(fromPeer == null || event.peerID == fromPeer)
|
||||||
|
if (matches && event is LiveVoiceEvent.Finished) {
|
||||||
|
finished = event
|
||||||
|
liveSnapshot = runCatching {
|
||||||
|
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
|
||||||
|
snapshot.parentFile?.mkdirs()
|
||||||
|
File(event.path).copyTo(snapshot, overwrite = true)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
matches && event is LiveVoiceEvent.Absorbed
|
||||||
|
} as LiveVoiceEvent.Absorbed
|
||||||
|
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
|
||||||
|
val analysis = liveSnapshot?.let { snapshot ->
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
|
||||||
|
} finally {
|
||||||
|
snapshot.delete()
|
||||||
|
}
|
||||||
|
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
|
||||||
|
return ok("ptt_recv")
|
||||||
|
.put("live_observed", finished != null)
|
||||||
|
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
|
||||||
|
.put("from", absorbed.peerID)
|
||||||
|
.put("burst_id", absorbed.burstID)
|
||||||
|
.put("frames", finished?.frames ?: 0)
|
||||||
|
.put("data_packets", finished?.dataPackets ?: 0)
|
||||||
|
.put("bytes", finished?.bytes ?: 0)
|
||||||
|
.put("expected_packets", finished?.expectedPackets ?: 0)
|
||||||
|
.put("missing_packets", finished?.missingPackets ?: 0)
|
||||||
|
.put("decoded_samples", analysis.decodedSamples)
|
||||||
|
.put("rms", analysis.rms)
|
||||||
|
.put("silent_block_fraction", analysis.silentBlockFraction)
|
||||||
|
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
|
||||||
|
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw packet injection
|
||||||
|
|
||||||
|
private fun rawSend(context: Context, intent: Intent): JSONObject {
|
||||||
|
val payloadHex = intent.requiredString("payload_hex")
|
||||||
|
val typeStr = intent.requiredString("type")
|
||||||
|
val peerID = intent.getStringExtra("peer")
|
||||||
|
val ttl = intent.getIntExtra("ttl", 7)
|
||||||
|
val type = typeStr.toUIntOrNull(16)?.toUByte()
|
||||||
|
?: return err("raw_send", "invalid type hex: $typeStr")
|
||||||
|
val payload = hexToBytes(payloadHex)
|
||||||
|
?: return err("raw_send", "invalid payload_hex")
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val packet = BitchatPacket(
|
||||||
|
type = type,
|
||||||
|
ttl = ttl.toUByte(),
|
||||||
|
senderID = mesh.myPeerID,
|
||||||
|
payload = payload
|
||||||
|
)
|
||||||
|
if (peerID != null) {
|
||||||
|
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
|
||||||
|
} else {
|
||||||
|
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
|
||||||
|
}
|
||||||
|
return ok("raw_send")
|
||||||
|
.put("type", typeStr)
|
||||||
|
.put("payload_bytes", payload.size)
|
||||||
|
.put("peer", peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport / state
|
||||||
|
|
||||||
|
private fun setBle(enabled: Boolean): JSONObject {
|
||||||
|
val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running")
|
||||||
|
ble.setBleTransportEnabled(enabled)
|
||||||
|
return ok("ble").put("enabled", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun state(context: Context): JSONObject {
|
||||||
|
val mesh = mesh(context)
|
||||||
|
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
|
||||||
|
val sessions = JSONObject()
|
||||||
|
AppStateStore.peers.value.forEach { peerID ->
|
||||||
|
sessions.put(peerID, mesh.getSessionState(peerID).toString())
|
||||||
|
}
|
||||||
|
return ok("state")
|
||||||
|
.put("peer_id", mesh.myPeerID)
|
||||||
|
.put("nickname", AppStateStore.nickname.value)
|
||||||
|
.put("peers", peersJson)
|
||||||
|
.put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList()))
|
||||||
|
.put("sessions", sessions)
|
||||||
|
.put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>))
|
||||||
|
.put("debug_status", mesh.getDebugStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearResults(context: Context): JSONObject {
|
||||||
|
val dir = File(context.cacheDir, "testhook/results")
|
||||||
|
val count = dir.listFiles()?.count { it.delete() } ?: 0
|
||||||
|
return ok("clear_results").put("deleted", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context)
|
||||||
|
|
||||||
|
private fun peerInfosJson(mesh: MeshService, peerIds: List<String>): JSONArray {
|
||||||
|
val nicknames = mesh.getPeerNicknames()
|
||||||
|
val rssi = mesh.getPeerRSSI()
|
||||||
|
val arr = JSONArray()
|
||||||
|
peerIds.forEach { id ->
|
||||||
|
val info = mesh.getPeerInfo(id)
|
||||||
|
arr.put(JSONObject()
|
||||||
|
.put("id", id)
|
||||||
|
.put("nickname", nicknames[id] ?: info?.nickname)
|
||||||
|
.put("rssi", rssi[id])
|
||||||
|
.put("direct", AppStateStore.directPeers.value.contains(id))
|
||||||
|
.put("connected", info?.isConnected)
|
||||||
|
.put("last_seen", info?.lastSeen)
|
||||||
|
.put("session", mesh.getSessionState(id).toString())
|
||||||
|
.put("fingerprint", mesh.getPeerFingerprint(id)))
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd)
|
||||||
|
private fun err(cmd: String, message: String) =
|
||||||
|
JSONObject().put("status", "error").put("cmd", cmd).put("error", message)
|
||||||
|
|
||||||
|
private fun Intent.requiredString(name: String): String =
|
||||||
|
getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name")
|
||||||
|
|
||||||
|
private fun sha256Hex(data: ByteArray): String =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(data).toHex()
|
||||||
|
|
||||||
|
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray? {
|
||||||
|
val clean = hex.replace(" ", "")
|
||||||
|
if (clean.length % 2 != 0) return null
|
||||||
|
return try {
|
||||||
|
ByteArray(clean.length / 2) { i ->
|
||||||
|
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package com.bitchat.android.testhook
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADB-drivable test hook (debug builds only).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \
|
||||||
|
* --es cmd <command> --es id <cmd-id> [command extras...]
|
||||||
|
*
|
||||||
|
* Result is written to cache/testhook/results/<id>.json and logged under tag TestHook:
|
||||||
|
* adb shell run-as com.bitchat.droid cat cache/testhook/results/<id>.json
|
||||||
|
*/
|
||||||
|
class TestHookReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "TestHook"
|
||||||
|
const val ACTION = "com.bitchat.droid.TEST_HOOK"
|
||||||
|
private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != ACTION) return
|
||||||
|
val cmd = intent.getStringExtra("cmd") ?: "ping"
|
||||||
|
val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}"
|
||||||
|
val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS)
|
||||||
|
|
||||||
|
Log.i(TAG, "CMD id=$id cmd=$cmd")
|
||||||
|
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
Thread {
|
||||||
|
val result = try {
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(overallTimeout) {
|
||||||
|
TestHookDriver.execute(context.applicationContext, cmd, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
JSONObject()
|
||||||
|
.put("status", "error")
|
||||||
|
.put("cmd", cmd)
|
||||||
|
.put("error", "${e.javaClass.simpleName}: ${e.message}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() }
|
||||||
|
File(dir, "$id.json").writeText(result.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to write result file for $id: ${e.message}")
|
||||||
|
}
|
||||||
|
Log.i(TAG, "RESULT id=$id $result")
|
||||||
|
}.start()
|
||||||
|
// Finish immediately: long-running commands continue on the worker thread and
|
||||||
|
// report via the result file. Holding the broadcast open past the system
|
||||||
|
// broadcast window would ANR the app.
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Internet permissions for Nostr relay connections -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Bluetooth permissions -->
|
<!-- Bluetooth permissions -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
@ -12,10 +17,46 @@
|
|||||||
<!-- Location permission required for BLE scanning -->
|
<!-- Location permission required for BLE scanning -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
|
||||||
<!-- Notification permissions -->
|
<!-- Notification permissions -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Wi‑Fi / Wi‑Fi Aware permissions (also used for hotspot APK sharing) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||||
|
<!-- Android 13+ runtime permission for Wi‑Fi operations (including Aware and Wi‑Fi P2P) -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
|
<!-- Android 17+ gates local network access; Wi‑Fi Aware peers over link-local IPv6 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
<!-- Keep hotspot alive while sharing the APK -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Signature permission for internal UI shutdown broadcasts -->
|
||||||
|
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
|
||||||
|
<!-- Foreground service and boot permissions for long-running background mesh -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<!-- Connected device foreground service type for BLE operations (API 34+) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
<!-- Data sync foreground service type (required when declaring dataSync) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<!-- Location foreground service type required for BLE scanning in background -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<!-- Microphone for voice notes -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<!-- Camera for QR verification -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Storage permissions for file sharing -->
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
|
||||||
<!-- Haptic feedback permission -->
|
<!-- Haptic feedback permission -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
@ -25,6 +66,13 @@
|
|||||||
<!-- Hardware features -->
|
<!-- Hardware features -->
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
|
||||||
|
<!-- Device support hint for Wi‑Fi Aware (optional) -->
|
||||||
|
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
|
<permission
|
||||||
|
android:name="com.bitchat.android.permission.FORCE_FINISH"
|
||||||
|
android:protectionLevel="signature" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BitchatApplication"
|
android:name=".BitchatApplication"
|
||||||
@ -32,23 +80,92 @@
|
|||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BitchatAndroid"
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
tools:targetApi="31">
|
tools:targetApi="33">
|
||||||
|
<service
|
||||||
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="autoStoreLocales"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- FileProvider for sharing temp/cache files with external viewers -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.GeohashPickerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.BitchatAndroid"
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="singleTop">
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="bitchat" android:host="verify" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Persistent foreground service to run the mesh in background -->
|
||||||
|
<service
|
||||||
|
android:name=".service.MeshForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice|dataSync|location"
|
||||||
|
tools:ignore="DataExtractionRules">
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.ConversationNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Auto-start mesh service after boot if enabled -->
|
||||||
|
<receiver
|
||||||
|
android:name=".service.BootCompletedReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Hotspot Activity for offline APK sharing -->
|
||||||
|
<activity
|
||||||
|
android:name=".hotspot.HotspotActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="Share BitChat"
|
||||||
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
|
||||||
|
<!-- Declare the foreground service type for WorkManager's foreground
|
||||||
|
service so the APK download worker can run as dataSync work -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
6
app/src/main/assets/design-icons/README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Design-spec icons
|
||||||
|
|
||||||
|
These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching
|
||||||
|
Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose.
|
||||||
|
Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the
|
||||||
|
call site so selected and disabled states remain theme-aware.
|
||||||
3
app/src/main/assets/design-icons/bookmark-filled.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 370 B |
3
app/src/main/assets/design-icons/bookmark-outline.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 362 B |
6
app/src/main/assets/design-icons/chat-bubbles.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -370)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M28.5 370.625H18.5C18.0027 370.625 17.5258 370.823 17.1742 371.174C16.8225 371.526 16.625 372.003 16.625 372.5V378.75C16.625 379.247 16.8225 379.724 17.1742 380.076C17.5258 380.427 18.0027 380.625 18.5 380.625H20.375V384.375L24.125 380.625H28.5C28.9973 380.625 29.4742 380.427 29.8258 380.076C30.1775 379.724 30.375 379.247 30.375 378.75V372.5C30.375 372.003 30.1775 371.526 29.8258 371.174C29.4742 370.823 28.9973 370.625 28.5 370.625Z"/>
|
||||||
|
<path d="M24.125 385.625H27.875L31.625 389.375V385.625H33.5C33.9973 385.625 34.4742 385.427 34.8258 385.076C35.1775 384.724 35.375 384.247 35.375 383.75V377.5C35.375 377.003 35.1775 376.526 34.8258 376.174C34.4742 375.823 33.9973 375.625 33.5 375.625H32.875"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 955 B |
6
app/src/main/assets/design-icons/command.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -407)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M26.625 422.875H35.375"/>
|
||||||
|
<path d="M16.625 407.875L24.125 415.375L16.625 422.875"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 338 B |
6
app/src/main/assets/design-icons/eye-off.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -682)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16.625 692C16.625 692 20.375 685.125 26 685.125C31.625 685.125 35.375 692 35.375 692C35.375 692 31.625 698.875 26 698.875C20.375 698.875 16.625 692 16.625 692Z"/>
|
||||||
|
<path d="M22.25 692C22.25 689.929 23.9288 688.25 26 688.25M29.75 692C29.75 694.071 28.0712 695.75 26 695.75M17.25 700.75L34.75 683.25"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 555 B |
7
app/src/main/assets/design-icons/globe.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -278)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M25.9999 297.375C28.2187 297.375 30.0174 293.178 30.0174 288C30.0174 282.822 28.2187 278.625 25.9999 278.625C23.7811 278.625 21.9824 282.822 21.9824 288C21.9824 293.178 23.7811 297.375 25.9999 297.375Z"/>
|
||||||
|
<path d="M16.625 288H35.375"/>
|
||||||
|
<path d="M26 297.375C31.1777 297.375 35.375 293.178 35.375 288C35.375 282.822 31.1777 278.625 26 278.625C20.8223 278.625 16.625 282.822 16.625 288C16.625 293.178 20.8223 297.375 26 297.375Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 687 B |
7
app/src/main/assets/design-icons/lock.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -310)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M32.25 329.375H19.75C18.715 329.375 17.875 328.535 17.875 327.5V321.25C17.875 320.215 18.715 319.375 19.75 319.375H32.25C33.285 319.375 34.125 320.215 34.125 321.25V327.5C34.125 328.535 33.285 329.375 32.25 329.375Z"/>
|
||||||
|
<path d="M21.625 316.875V315C21.625 312.584 23.5838 310.625 26 310.625C28.4162 310.625 30.375 312.584 30.375 315V316.875"/>
|
||||||
|
<path d="M26 326.25C27.0355 326.25 27.875 325.411 27.875 324.375C27.875 323.339 27.0355 322.5 26 322.5C24.9645 322.5 24.125 323.339 24.125 324.375C24.125 325.411 24.9645 326.25 26 326.25Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 792 B |
6
app/src/main/assets/design-icons/mention.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -582)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M29.5236 587.888V594.4C29.5236 596.41 32.5761 596.856 34.1749 594.139C35.5299 591.84 35.1974 588.334 33.5049 586.025C31.0149 582.629 25.2574 581.359 21.0736 584.166C17.2311 586.746 15.8624 591.968 17.9861 596.188C20.0874 600.364 25.0199 602.386 29.4861 600.876"/>
|
||||||
|
<path d="M25.8485 595.785C27.8698 595.785 29.5085 594.032 29.5085 591.87C29.5085 589.708 27.8698 587.955 25.8485 587.955C23.8271 587.955 22.1885 589.708 22.1885 591.87C22.1885 594.032 23.8271 595.785 25.8485 595.785Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 738 B |
5
app/src/main/assets/design-icons/on-location-person.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 395 B |
3
app/src/main/assets/design-icons/panic.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path transform="translate(-16 -786)" d="M17.875 805.375H22.875M20.375 786.625V789.125M20.375 789.125L27.875 796.625M16.625 790.375L22.875 796.625M30.375 789.125L35.375 794.125M25.375 789.125L32.875 796.625M35.375 789.125H16.625V796.625H35.375V789.125ZM20.375 805.375V799.125M29.125 805.375H34.125M31.625 786.625V789.125M31.625 805.375V799.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 541 B |
6
app/src/main/assets/design-icons/people.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -314)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M28.2187 328.116L25.14 327.241C25.0239 327.208 24.9198 327.142 24.8404 327.051C24.761 326.96 24.7096 326.848 24.6925 326.729L24.4338 324.907C25.0869 324.609 25.6407 324.129 26.0291 323.525C26.4176 322.921 26.6244 322.218 26.625 321.5V319.782C26.6402 318.788 26.2704 317.826 25.593 317.098C24.9156 316.37 23.9829 315.932 22.99 315.875C22.488 315.86 21.9879 315.945 21.5196 316.127C21.0513 316.308 20.6242 316.582 20.2637 316.932C19.9032 317.282 19.6166 317.7 19.421 318.163C19.2254 318.625 19.1248 319.123 19.125 319.625V321.5C19.1256 322.218 19.3324 322.921 19.7209 323.525C20.1093 324.129 20.6631 324.609 21.3162 324.907L21.0575 326.724C21.0404 326.843 20.989 326.955 20.9096 327.046C20.8302 327.137 20.7261 327.203 20.61 327.236L17.5313 328.111C17.2702 328.186 17.0406 328.343 16.8771 328.56C16.7136 328.777 16.6251 329.041 16.625 329.312V332.125H29.125V329.317C29.1249 329.046 29.0364 328.782 28.8729 328.565C28.7094 328.348 28.4798 328.191 28.2187 328.116Z"/>
|
||||||
|
<path d="M31.625 332.125H35.375V328.101C35.375 327.823 35.2819 327.552 35.1104 327.332C34.939 327.113 34.6991 326.956 34.4288 326.889L30.7825 325.977C30.6618 325.947 30.5528 325.882 30.4695 325.789C30.3863 325.697 30.3324 325.582 30.315 325.459L30.0587 323.657C30.7119 323.359 31.2657 322.879 31.6541 322.275C32.0426 321.671 32.2494 320.968 32.25 320.25V318.532C32.2652 317.538 31.8954 316.576 31.218 315.848C30.5406 315.12 29.6079 314.682 28.615 314.625C27.9181 314.603 27.229 314.777 26.625 315.125"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
6
app/src/main/assets/design-icons/person.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M27.725 252.271L28.1875 255.125H31.48C32.3075 255.125 33.0375 255.669 33.2763 256.461L34.75 261.375H17.25L18.7237 256.461C18.9612 255.669 19.6912 255.125 20.52 255.125H23.8125L24.275 252.271"/>
|
||||||
|
<path d="M30.375 247C30.375 244.584 28.4162 242.625 26 242.625C23.5838 242.625 21.625 244.584 21.625 247V248.25C21.625 250.666 23.5838 252.625 26 252.625C28.4162 252.625 30.375 250.666 30.375 248.25V247Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 655 B |
6
app/src/main/assets/design-icons/range.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -378)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M25.375 386.125C26.0654 386.125 26.625 385.565 26.625 384.875C26.625 384.185 26.0654 383.625 25.375 383.625C24.6846 383.625 24.125 384.185 24.125 384.875C24.125 385.565 24.6846 386.125 25.375 386.125Z"/>
|
||||||
|
<path d="M25.375 397.375V386.125M21.625 397.375H29.125M28.9102 388.41C29.8475 387.472 30.3741 386.201 30.3741 384.875C30.3741 383.549 29.8475 382.278 28.9102 381.34M31.5625 391.062C32.3751 390.25 33.0197 389.285 33.4595 388.224C33.8993 387.162 34.1256 386.024 34.1256 384.875C34.1256 383.726 33.8993 382.588 33.4595 381.526C33.0197 380.465 32.3751 379.5 31.5625 378.688M21.8399 388.41C20.9026 387.472 20.376 386.201 20.376 384.875C20.376 383.549 20.9026 382.278 21.8399 381.34M19.1876 391.062C18.375 390.25 17.7304 389.285 17.2907 388.224C16.8509 387.162 16.6245 386.024 16.6245 384.875C16.6245 383.726 16.8509 382.588 17.2907 381.526C17.7304 380.465 18.375 379.5 19.1876 378.688"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/src/main/assets/design-icons/shuffle.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path transform="translate(-16 -734)" d="M16.625 749.625H16.88C18.3088 749.625 19.6688 749.014 20.6175 747.946L27.6338 740.053C28.5825 738.985 29.9425 738.374 31.3712 738.374H35.375M31.625 734.625L35.375 738.375L31.625 742.125M26.3477 746.5L27.6339 747.946C28.5827 749.014 29.9427 749.625 31.3714 749.625H35.3752M16.625 738.375H16.88C18.3088 738.375 19.6688 738.986 20.6175 740.054L21.9025 741.5M31.625 753.375L35.375 749.625L31.625 745.875" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 638 B |
3
app/src/main/assets/design-icons/star-filled.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" fill="currentColor" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
3
app/src/main/assets/design-icons/star.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 380 B |
5
app/src/main/assets/design-icons/teleport.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="8" cy="8" r="7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
|
||||||
|
<circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="11" cy="6" r="1" fill="currentColor"/>
|
||||||
|
<path d="M5.5 10.5H10.5" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 405 B |
3
app/src/main/assets/design-icons/waveform.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 5H2.5V12.5H0ZM2.5 2.5H5V15H2.5ZM5 0H7.5V15H5ZM7.5 0H10V17.5H7.5ZM10 0H12.5V17.5H10ZM12.5 0H15V20H12.5ZM15 2.5H17.5V15H15ZM17.5 5H20V12.5H17.5Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
8
app/src/main/assets/design-icons/wifi-off.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21.1387 252.139C22.4278 250.851 24.1757 250.127 25.998 250.127C27.8204 250.127 29.5683 250.851 30.8574 252.139"/>
|
||||||
|
<path d="M27.25 255.601C27.4831 255.81 27.6603 256.073 27.7656 256.368C27.8708 256.662 27.9008 256.978 27.8527 257.287C27.8046 257.596 27.68 257.888 27.4902 258.137C27.3004 258.385 27.0514 258.582 26.766 258.71C26.4806 258.838 26.1677 258.892 25.856 258.868C25.5442 258.844 25.2433 258.743 24.9809 258.573C24.7184 258.403 24.5026 258.17 24.3531 257.895C24.2036 257.62 24.1252 257.313 24.125 257"/>
|
||||||
|
<path d="M17.6025 248.604C19.8294 246.378 22.8493 245.127 25.9982 245.127C29.147 245.127 32.1669 246.378 34.3938 248.604"/>
|
||||||
|
<path d="M34.125 243.875L19.125 258.875"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 942 B |
416
app/src/main/assets/nostr_relays.csv
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
Relay URL,Latitude,Longitude
|
||||||
|
nostr.bitcoiner.social,40.7608,-111.891
|
||||||
|
relay.mostr.pub,43.6532,-79.3832
|
||||||
|
relay.veganostr.com:443,60.1699,24.9384
|
||||||
|
relay.dreamith.to,43.6532,-79.3832
|
||||||
|
buzz.cashu.space,50.1109,8.68213
|
||||||
|
relay.sincensura.org,43.6532,-79.3832
|
||||||
|
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||||
|
nostr.bond,50.1109,8.68213
|
||||||
|
relay.edufeed.org,49.4521,11.0767
|
||||||
|
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||||
|
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||||
|
relay2.fiatdenier.com,50.1013,8.62643
|
||||||
|
relay.mwaters.net,50.9871,2.12554
|
||||||
|
relay2.angor.io,48.1046,11.6002
|
||||||
|
memlay.v0l.io,53.3498,-6.26031
|
||||||
|
nostr.wecsats.io,43.6532,-79.3832
|
||||||
|
vm-1734.lnvps.cloud,53.3498,-6.26031
|
||||||
|
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||||
|
nostr2.girino.org:443,43.6532,-79.3832
|
||||||
|
nostr.robosats.org,64.1476,-21.9392
|
||||||
|
relay.atsocy.com,43.6532,-79.3832
|
||||||
|
relayone.geektank.ai:443,39.0997,-94.5786
|
||||||
|
relay.olas.app:443,60.1699,24.9384
|
||||||
|
relay.nostu.be,40.4167,-3.70329
|
||||||
|
relay.otrta.me,50.1109,8.68213
|
||||||
|
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||||
|
rilo.nostria.app,43.6532,-79.3832
|
||||||
|
relay.agorist.space,52.3734,4.89406
|
||||||
|
relay.nostrmap.net,60.1699,24.9384
|
||||||
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
|
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||||
|
relay.angor.io:443,48.1046,11.6002
|
||||||
|
nostr.rtvslawenia.com,49.4543,11.0746
|
||||||
|
relay.wasabiwallet.io,43.6532,-79.3832
|
||||||
|
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||||
|
nostr.4rs.nl,49.0291,8.35696
|
||||||
|
nexus.libernet.app,43.6532,-79.3832
|
||||||
|
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||||
|
nostr.88mph.life,52.1941,-2.21905
|
||||||
|
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||||
|
social.amanah.eblessing.co,48.1046,11.6002
|
||||||
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
|
relay.44billion.net,43.6532,-79.3832
|
||||||
|
relay.conduit.market,38.7946,-106.535
|
||||||
|
nostr.infero.net,35.6764,139.65
|
||||||
|
relay.staging.plebeian.market,51.5072,-0.127586
|
||||||
|
relay.snort.social,53.3498,-6.26031
|
||||||
|
purplerelay.com:443,43.6532,-79.3832
|
||||||
|
nostr.mas-family.eu,60.3478,15.7505
|
||||||
|
relay.ru.ac.th,13.7607,100.627
|
||||||
|
relay.openresist.com,43.6532,-79.3832
|
||||||
|
antiprimal.net,43.6532,-79.3832
|
||||||
|
relay.illuminodes.com,43.6532,-79.3832
|
||||||
|
relay.mmwaves.de:443,48.8575,2.35138
|
||||||
|
nostr.spicyz.io,43.6532,-79.3832
|
||||||
|
relay.nostrfeed.com,60.1699,24.9384
|
||||||
|
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||||
|
relay.directsponsor.net,42.8864,-78.8784
|
||||||
|
relay.nostrhub.fr,48.1045,11.6004
|
||||||
|
nostr.plantroon.com,50.1013,8.62643
|
||||||
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
|
relay.bnos.space:443,43.6532,-79.3832
|
||||||
|
relay.pyramid.li,47.4093,8.46503
|
||||||
|
public.crostr.com,43.6532,-79.3832
|
||||||
|
relay.kilombino.com,43.6532,-79.3832
|
||||||
|
relay.libernet.app:443,43.6532,-79.3832
|
||||||
|
relay.internationalright-wing.org,-22.4692,-48.9875
|
||||||
|
nostr.overmind.lol:443,43.6532,-79.3832
|
||||||
|
relay.ordoplay.com,50.1109,8.68213
|
||||||
|
mostro-p2p.tech,50.1109,8.68213
|
||||||
|
offchain.pub:443,39.1585,-94.5728
|
||||||
|
cs-relay.nostrdev.com,50.4754,12.3683
|
||||||
|
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||||
|
nostr.unkn0wn.world,46.8499,9.53287
|
||||||
|
relay.shadowbip.com,50.1109,8.68213
|
||||||
|
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||||
|
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||||
|
relay2.angor.io:443,48.1046,11.6002
|
||||||
|
relay.arx-ccn.com,50.4754,12.3683
|
||||||
|
relay.underorion.se,50.1109,8.68213
|
||||||
|
bread.nostrsms.com,40.8218,-74.45
|
||||||
|
relay.cypherflow.ai,48.8575,2.35138
|
||||||
|
nostr.islandarea.net:443,35.4669,-97.6473
|
||||||
|
relay.homeinhk.xyz,35.694,139.754
|
||||||
|
relay.ditto.pub:443,43.6532,-79.3832
|
||||||
|
nostr.fullstackcash.net,45.5201,-122.99
|
||||||
|
bitcoinostr.duckdns.org,38.9504,-0.14007
|
||||||
|
relay.trotters.cc,43.6532,-79.3832
|
||||||
|
portal-relay.pareto.space,49.0291,8.35696
|
||||||
|
relay.openresist.com:443,43.6532,-79.3832
|
||||||
|
relay.endfiat.money,59.3327,18.0656
|
||||||
|
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||||
|
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||||
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
|
relay5.bitransfer.org,43.6532,-79.3832
|
||||||
|
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||||
|
testr.nymble.world,40.8054,-74.0241
|
||||||
|
strfry.shock.network:443,39.0438,-77.4874
|
||||||
|
chorus.mikedilger.com:444,-36.8906,174.794
|
||||||
|
relay.minibolt.info,43.6532,-79.3832
|
||||||
|
nos.lol:443,50.4754,12.3683
|
||||||
|
relay.liberbitworld.org,43.6532,-79.3832
|
||||||
|
maxq.descendant.io,43.6532,-79.3832
|
||||||
|
nostrcity-club.fly.dev,38.7946,-106.535
|
||||||
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
|
nostr.n7ekb.net,47.4941,-122.294
|
||||||
|
relay.erybody.com,41.4513,-81.7021
|
||||||
|
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||||
|
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||||
|
syb.lol,34.0549,-118.243
|
||||||
|
relay.nostr.com,50.1109,8.68213
|
||||||
|
relay.hivetalk.org,40.8302,-74.1299
|
||||||
|
relay.degmods.com,50.4754,12.3683
|
||||||
|
relay.laantungir.net,-19.4692,-42.5315
|
||||||
|
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||||
|
relay.tdw.lol,41.8781,-87.6298
|
||||||
|
ribo.us.nostria.app,43.6532,-79.3832
|
||||||
|
public.obelisk.ar,43.6532,-79.3832
|
||||||
|
prl.plus,55.7628,37.5983
|
||||||
|
21milionidinostr.duckdns.org,45.5192,9.08625
|
||||||
|
nostr.carroarmato0.be,50.914,3.21378
|
||||||
|
nostr.pbfs.io:443,50.4754,12.3683
|
||||||
|
public.crostr.com:443,43.6532,-79.3832
|
||||||
|
espelho.girino.org,43.6532,-79.3832
|
||||||
|
nostr.computingcache.com,45.5341,-122.956
|
||||||
|
relay.chorus.community:443,48.5333,10.7
|
||||||
|
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||||
|
nostr.easycryptosend.it,43.6532,-79.3832
|
||||||
|
no.str.cr:443,8.96171,-83.5246
|
||||||
|
nostr.snowbla.de:443,50.4754,12.3683
|
||||||
|
bridge.tagomago.me,42.3601,-71.0589
|
||||||
|
relay.endfiat.money:443,59.3327,18.0656
|
||||||
|
relay.klabo.world,47.674,-122.122
|
||||||
|
articles.layer3.news:443,37.3387,-121.885
|
||||||
|
nostr.21crypto.ch,47.5356,8.73209
|
||||||
|
relay.kaleidoswap.com,50.8476,4.35717
|
||||||
|
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||||
|
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||||
|
strfry.ymir.cloud,43.6532,-79.3832
|
||||||
|
relay.littlebitstudios.com,43.6532,-79.3832
|
||||||
|
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||||
|
relay.islandbitcoin.com,12.8498,77.6545
|
||||||
|
relay.zone667.com,60.1699,24.9384
|
||||||
|
relay.lightning.pub:443,39.0438,-77.4874
|
||||||
|
relay.layer.systems:443,49.0291,8.35695
|
||||||
|
offchain.pub,39.1585,-94.5728
|
||||||
|
nos.lol,50.4754,12.3683
|
||||||
|
cdn.satellite.earth,40.8302,-74.1299
|
||||||
|
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||||
|
relay.wisp.talk:443,49.4543,11.0746
|
||||||
|
nostr.hekster.org:443,37.3986,-121.964
|
||||||
|
nostr.mom:443,50.4754,12.3683
|
||||||
|
relay.mccormick.cx:443,52.3563,4.95714
|
||||||
|
dev.relay.stream,43.6532,-79.3832
|
||||||
|
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||||
|
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
relay.agorist.space:443,52.3734,4.89406
|
||||||
|
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||||
|
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||||
|
node.kommonzenze.de,49.4521,11.0767
|
||||||
|
relay.beamhop.com,43.6532,-79.3832
|
||||||
|
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||||
|
relay.chatbett.de,40.7128,-74.006
|
||||||
|
relay.tdw.gg,34.0549,-118.243
|
||||||
|
budabit.nostr1.com,40.7057,-74.0136
|
||||||
|
nostr.novacisko.cz,52.2026,20.9397
|
||||||
|
relay.samt.st,40.8302,-74.1299
|
||||||
|
bruh.samt.st,43.6532,-79.3832
|
||||||
|
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||||
|
relay.opossumwire.org,34.0549,-118.243
|
||||||
|
relay.angor.io,48.1046,11.6002
|
||||||
|
relay.nostrmap.net:443,60.1699,24.9384
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||||
|
chat-relay.zap-work.com,43.6532,-79.3832
|
||||||
|
relay.earthly.city,34.1749,-118.54
|
||||||
|
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||||
|
relay.satmaxt.xyz,43.6532,-79.3832
|
||||||
|
relay.primal.net,43.6532,-79.3832
|
||||||
|
relay.bornheimer.app,50.1109,8.68213
|
||||||
|
nostr.tagomago.me,42.3601,-71.0589
|
||||||
|
relay.orly.dev,32.7767,-96.797
|
||||||
|
no.str.cr,8.96171,-83.5246
|
||||||
|
auth.nostr1.com,40.7057,-74.0136
|
||||||
|
relay.opmaat.org,60.1699,24.9384
|
||||||
|
temp.iris.to,43.6532,-79.3832
|
||||||
|
conduitl2.fly.dev,38.7946,-106.535
|
||||||
|
yabu.me,35.6092,139.73
|
||||||
|
nostrride.io,37.3986,-121.964
|
||||||
|
nostr.plantroon.com:443,50.1013,8.62643
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
nostrbtc.com,43.6532,-79.3832
|
||||||
|
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||||
|
relay.manneken.brussels,49.4543,11.0746
|
||||||
|
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||||
|
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||||
|
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||||
|
nostr.nodesmap.com,59.3327,18.0656
|
||||||
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
|
bunk-test.feeds.relay.tools,38.6327,-90.1961
|
||||||
|
relay.btcforplebs.com,43.6532,-79.3832
|
||||||
|
relay.beginningend.com,35.2227,-97.4786
|
||||||
|
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||||
|
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||||
|
relay.nostrian-conquest.com,41.223,-111.974
|
||||||
|
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||||
|
nostr.oxtr.dev:443,50.4754,12.3683
|
||||||
|
nostr-relay.acloud.kdns.fr,43.6532,-79.3832
|
||||||
|
relay.mccormick.cx,52.3563,4.95714
|
||||||
|
relay.fiatdenier.com,43.7787,-79.5393
|
||||||
|
nostr.bitcoiner.social:443,40.7608,-111.891
|
||||||
|
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||||
|
relay.piazza.today,48.122,11.589
|
||||||
|
nostr.ac,38.958,-77.3592
|
||||||
|
strfry.bonsai.com,39.0438,-77.4874
|
||||||
|
dev-relay.nostreon.com,60.1699,24.9384
|
||||||
|
relay.olas.app,60.1699,24.9384
|
||||||
|
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||||
|
strfry.bonsai.com:443,39.0438,-77.4874
|
||||||
|
nostr.tac.lol,47.4748,-122.273
|
||||||
|
rilo.nostria.app:443,43.6532,-79.3832
|
||||||
|
nostr.azzamo.net,52.2633,21.0283
|
||||||
|
nostr.wecsats.io:443,43.6532,-79.3832
|
||||||
|
relay.wellorder.net,45.5201,-122.99
|
||||||
|
relay.libernet.app,43.6532,-79.3832
|
||||||
|
nostr.thalheim.io,60.1699,24.9384
|
||||||
|
relayrs.notoshi.win,43.6532,-79.3832
|
||||||
|
ribo.nostria.app:443,43.6532,-79.3832
|
||||||
|
relay.gulugulu.moe,43.6532,-79.3832
|
||||||
|
relay.qstr.app,50.1109,8.68213
|
||||||
|
myvoiceourstory.org,37.3598,-121.981
|
||||||
|
nostr-relay.zimage.com,34.282,-118.439
|
||||||
|
treuzkas.branruz.com,48.8575,2.35138
|
||||||
|
top.testrelay.top,43.6532,-79.3832
|
||||||
|
relay.cyberguy.fyi,52.6907,4.8181
|
||||||
|
articles.layer3.news,37.3387,-121.885
|
||||||
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
|
nostr.carroarmato0.be:443,50.914,3.21378
|
||||||
|
nostr-02.uid.ovh,50.9871,2.12554
|
||||||
|
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||||
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
|
nostr.chaima.info:443,50.1109,8.68213
|
||||||
|
relay.bnos.space,43.6532,-79.3832
|
||||||
|
relay.solife.me,43.6532,-79.3832
|
||||||
|
relay.wavlake.com:443,41.2619,-95.8608
|
||||||
|
bucket.coracle.social,37.7775,-122.397
|
||||||
|
nostr.christiansass.de,52.5244,13.4105
|
||||||
|
relay.dyne.org,49.0291,8.35705
|
||||||
|
testnet-relay.samt.st,40.8302,-74.1299
|
||||||
|
nostream.seitendan.com,34.7062,135.493
|
||||||
|
basspistol.org,49.0291,8.35696
|
||||||
|
purplerelay.com,43.6532,-79.3832
|
||||||
|
relay.wisp.talk,49.4543,11.0746
|
||||||
|
relay.getsafebox.app:443,43.6532,-79.3832
|
||||||
|
freelay.sovbit.host,60.1699,24.9384
|
||||||
|
relay.sigit.io:443,50.4754,12.3683
|
||||||
|
buzz.ac2n-share.kozow.com,45.764,4.83566
|
||||||
|
relay.mostro.network,40.8302,-74.1299
|
||||||
|
nostrcheck.me,43.6532,-79.3832
|
||||||
|
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||||
|
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||||
|
nostr.vulpem.com,49.4543,11.0746
|
||||||
|
relay-na1.metanomalist.com,43.6532,-79.3832
|
||||||
|
chorus.bonsai.com,39.0438,-77.4874
|
||||||
|
ec2.f7z.io,60.1699,24.9384
|
||||||
|
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||||
|
nostr.twinkle.lol,51.902,7.6657
|
||||||
|
relay.lanavault.space,60.1699,24.9384
|
||||||
|
nostr.pbfs.io,50.4754,12.3683
|
||||||
|
bitchat.nostr1.com,40.7057,-74.0136
|
||||||
|
nostr.highway15.net,28.0445,-82.6699
|
||||||
|
nostr.tabordalab.com,60.1699,24.9384
|
||||||
|
relay.ohstr.com,43.6532,-79.3832
|
||||||
|
schnorr.me,43.6532,-79.3832
|
||||||
|
relay.s-w.art,43.6532,-79.3832
|
||||||
|
relay.nmail.li,50.9871,2.12554
|
||||||
|
bcast.girino.org,43.6532,-79.3832
|
||||||
|
relay.nostrops.com,32.71,-96.6745
|
||||||
|
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||||
|
relay.keykeeper.world,40.7824,-74.0711
|
||||||
|
relay.satsmarkt.club,52.6907,4.8181
|
||||||
|
adre.su,59.9311,30.3609
|
||||||
|
relay.edufeed.org:443,49.4521,11.0767
|
||||||
|
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||||
|
relay.lightning.pub,39.0438,-77.4874
|
||||||
|
porchlight.social,43.6532,-79.3832
|
||||||
|
nostr.snowbla.de,50.4754,12.3683
|
||||||
|
relay.trotters.cc:443,43.6532,-79.3832
|
||||||
|
nostr-relay.xbytez.io,50.6924,3.20113
|
||||||
|
relay.nostr.net,43.6532,-79.3832
|
||||||
|
nostrcity-club.fly.dev:443,38.7946,-106.535
|
||||||
|
nostr.mikoshi.de,50.1109,8.68213
|
||||||
|
nostr.mom,50.4754,12.3683
|
||||||
|
schnorr.me:443,43.6532,-79.3832
|
||||||
|
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||||
|
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||||
|
relay.fckstate.net,59.3293,18.0686
|
||||||
|
offchain.bostr.online,43.6532,-79.3832
|
||||||
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
|
testnet.samt.st,43.6532,-79.3832
|
||||||
|
nostr.planix.org,43.6532,-79.3832
|
||||||
|
relay.getvia.xyz,60.1699,24.9384
|
||||||
|
relay.lacrypta.ar,43.6532,-79.3832
|
||||||
|
nostr.janx.com,43.6532,-79.3832
|
||||||
|
relay.yoinekodo.jp,43.6532,-79.3832
|
||||||
|
nostrelay.circum.space,52.2245,8.826
|
||||||
|
staging.yabu.me,35.6092,139.73
|
||||||
|
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||||
|
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||||
|
relay.satsapp.me,50.4754,12.3683
|
||||||
|
x.kojira.io,43.6532,-79.3832
|
||||||
|
relay.layer.systems,49.0291,8.35695
|
||||||
|
relay01.lnfi.network,35.6764,139.65
|
||||||
|
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||||
|
relay-rpi.edufeed.org,49.4521,11.0767
|
||||||
|
nostr-dev.wellorder.net,45.5201,-122.99
|
||||||
|
nostr.overmind.lol,43.6532,-79.3832
|
||||||
|
relay.aarpia.com,37.3986,-121.964
|
||||||
|
relayone.geektank.ai,39.0997,-94.5786
|
||||||
|
nostr.purpura.cloud,43.6532,-79.3832
|
||||||
|
nostr.data.haus:443,50.4754,12.3683
|
||||||
|
nexus.libernet.app:443,43.6532,-79.3832
|
||||||
|
nostr.thebiglake.org,32.71,-96.6745
|
||||||
|
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||||
|
relay.mitchelltribe.com,39.0438,-77.4874
|
||||||
|
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||||
|
bitcoiner.social,40.7608,-111.891
|
||||||
|
relay.guggero.org,46.5971,9.59652
|
||||||
|
relay.mrmave.work,43.6532,-79.3832
|
||||||
|
relay.flashapp.me,43.6548,-79.3885
|
||||||
|
relay.mmwaves.de,48.8575,2.35138
|
||||||
|
nostr.red5d.dev,43.6532,-79.3832
|
||||||
|
relay.plebeian.market,50.1109,8.68213
|
||||||
|
nostr.girino.org,43.6532,-79.3832
|
||||||
|
relay02.lnfi.network,35.6764,139.65
|
||||||
|
relay.vrtmrz.net,43.6532,-79.3832
|
||||||
|
ynostr.yael.at,60.1699,24.9384
|
||||||
|
relay.cosmicbolt.net,37.3986,-121.964
|
||||||
|
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||||
|
nostrelay.circum.space:443,52.2245,8.826
|
||||||
|
nostr.tac.lol:443,47.4748,-122.273
|
||||||
|
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||||
|
relay.agora.social,50.7383,15.0648
|
||||||
|
vault.iris.to:443,43.6532,-79.3832
|
||||||
|
relay.nexterz.com,43.6532,-79.3832
|
||||||
|
relay2.veganostr.com,60.1699,24.9384
|
||||||
|
strfry.shock.network,39.0438,-77.4874
|
||||||
|
relay.notoshi.win,13.3396,100.93
|
||||||
|
vault.iris.to,43.6532,-79.3832
|
||||||
|
relay.novospes.com,43.6532,-79.3832
|
||||||
|
nip85.nosfabrica.com,39.0997,-94.5786
|
||||||
|
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||||
|
nostr.stakey.net,52.3676,4.90414
|
||||||
|
syb.lol:443,34.0549,-118.243
|
||||||
|
nostr-01.uid.ovh,50.9871,2.12554
|
||||||
|
nostr-02.yakihonne.com,1.32123,103.695
|
||||||
|
relay.favillakey.io,-27.4705,153.026
|
||||||
|
relay.plebeian.market:443,50.1109,8.68213
|
||||||
|
r.iqbqioza.com,43.6532,-79.3832
|
||||||
|
relay.ohstr.com:443,43.6532,-79.3832
|
||||||
|
relay.paulstephenborile.com,49.4543,11.0746
|
||||||
|
nostr.2b9t.xyz,34.0549,-118.243
|
||||||
|
slick.mjex.me,39.0418,-77.4744
|
||||||
|
nostr.hekster.org,37.3986,-121.964
|
||||||
|
nostr.stakey.net:443,52.3676,4.90414
|
||||||
|
relay.sharegap.net,43.6532,-79.3832
|
||||||
|
relay.veganostr.com,60.1699,24.9384
|
||||||
|
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||||
|
nostr.data.haus,50.4754,12.3683
|
||||||
|
x.kojira.io:443,43.6532,-79.3832
|
||||||
|
relay.nostx.io,43.6532,-79.3832
|
||||||
|
wheat.happytavern.co,43.6532,-79.3832
|
||||||
|
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||||
|
inbox.scuba323.com,40.8218,-74.45
|
||||||
|
nostr.chaima.info,50.1109,8.68213
|
||||||
|
relay.nostriches.club,43.6532,-79.3832
|
||||||
|
fanfares.nostr1.com,40.7057,-74.0136
|
||||||
|
relay.bitmacro.cloud,43.6532,-79.3832
|
||||||
|
relay.mostr.pub:443,43.6532,-79.3832
|
||||||
|
nostr.spicyz.io:443,43.6532,-79.3832
|
||||||
|
relay.agentry.com,42.8864,-78.8784
|
||||||
|
nostr.thalheim.io:443,60.1699,24.9384
|
||||||
|
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
|
||||||
|
relay.mypathtofire.de,42.8864,-78.8784
|
||||||
|
nostr-relay.corb.net:443,39.6478,-104.988
|
||||||
|
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||||
|
nostr.debate.report,50.1109,8.68213
|
||||||
|
relay.nostrcheck.me,43.6532,-79.3832
|
||||||
|
relay.scuba323.com,40.8218,-74.45
|
||||||
|
relay.decentralia.fr,48.122,11.589
|
||||||
|
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||||
|
relay.sigit.io,50.4754,12.3683
|
||||||
|
ribo.nostria.app,43.6532,-79.3832
|
||||||
|
nostr.davenov.com,50.1109,8.68213
|
||||||
|
nostr.azzamo.net:443,52.2633,21.0283
|
||||||
|
ribo.eu.nostria.app,43.6532,-79.3832
|
||||||
|
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||||
|
desktop-r082364.tail4b3c94.ts.net,48.8566,2.35222
|
||||||
|
0x-nostr-relay.fly.dev,38.7946,-106.535
|
||||||
|
soloco.nl,43.6532,-79.3832
|
||||||
|
relay.chorus.community,48.5333,10.7
|
||||||
|
nostr.sathoarder.com:443,48.5734,7.75211
|
||||||
|
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||||
|
cache.trustr.ing,43.6548,-79.3885
|
||||||
|
nostr.islandarea.net,35.4669,-97.6473
|
||||||
|
nostr-relay.corb.net,39.6478,-104.988
|
||||||
|
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||||
|
rele.speyhard.fi,50.1109,8.68213
|
||||||
|
relay.pocketnostr.com,40.8054,-74.0241
|
||||||
|
relay.nuts.cash,52.3676,4.90414
|
||||||
|
spamspamspamspam.rest,43.6532,-79.3832
|
||||||
|
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||||
|
relay.satlantis.io,39.0438,-77.4874
|
||||||
|
nostr.na.social,43.6532,-79.3832
|
||||||
|
relay.nostreon.com,60.1699,24.9384
|
||||||
|
1
app/src/main/assets/world_borders.geojson
Normal file
1
app/src/main/assets/world_cities.geojson
Normal file
1
app/src/main/assets/world_land.geojson
Normal file
@ -1,16 +1,81 @@
|
|||||||
package com.bitchat.android
|
package com.bitchat.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.bitchat.android.nostr.RelayDirectory
|
||||||
|
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||||
|
import com.bitchat.android.net.ArtiTorManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class for bitchat Android
|
* Main application class for bitchat Android
|
||||||
*/
|
*/
|
||||||
class BitchatApplication : Application() {
|
class BitchatApplication : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
// Initialize any global services or configurations
|
// Start the single process-wide power policy before transport components are constructed.
|
||||||
// For now, keep it simple
|
com.bitchat.android.mesh.PowerManager.getInstance(this).start()
|
||||||
|
|
||||||
|
// Initialize Tor first so any early network goes over Tor
|
||||||
|
try {
|
||||||
|
val torProvider = ArtiTorManager.getInstance()
|
||||||
|
torProvider.init(this)
|
||||||
|
} catch (_: Exception){}
|
||||||
|
|
||||||
|
// Initialize relay directory (loads assets/nostr_relays.csv)
|
||||||
|
RelayDirectory.initialize(this)
|
||||||
|
|
||||||
|
// Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately
|
||||||
|
try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
|
||||||
|
try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Restore private conversations before background transports can deliver new messages.
|
||||||
|
// AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace
|
||||||
|
// newer transport state with an older database snapshot.
|
||||||
|
try {
|
||||||
|
com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Warm up Nostr identity to ensure npub is available for favorite notifications
|
||||||
|
try {
|
||||||
|
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize theme preference
|
||||||
|
ThemePreferenceManager.init(this)
|
||||||
|
|
||||||
|
// Initialize chat UI mode (matrix transcript vs bubbles)
|
||||||
|
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
|
||||||
|
|
||||||
|
// Initialize debug preference manager (persists debug toggles)
|
||||||
|
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize Wi‑Fi Aware controller with persisted default
|
||||||
|
try {
|
||||||
|
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
|
||||||
|
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize Geohash Registries for persistence
|
||||||
|
try {
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
|
||||||
|
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
|
||||||
|
// process level so closing the Activity does not disconnect Nostr.
|
||||||
|
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Initialize mesh service preferences
|
||||||
|
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Proactively start the foreground service to keep mesh alive
|
||||||
|
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
|
||||||
|
|
||||||
|
// TorManager already initialized above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,35 @@
|
|||||||
package com.bitchat.android
|
package com.bitchat.android
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.mesh.MeshService
|
||||||
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||||
import com.bitchat.android.onboarding.BluetoothStatus
|
import com.bitchat.android.onboarding.BluetoothStatus
|
||||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
import com.bitchat.android.onboarding.BluetoothStatusManager
|
||||||
import com.bitchat.android.onboarding.BatteryOptimizationManager
|
import com.bitchat.android.onboarding.BatteryOptimizationManager
|
||||||
|
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
|
||||||
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
||||||
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
||||||
|
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
|
||||||
import com.bitchat.android.onboarding.InitializationErrorScreen
|
import com.bitchat.android.onboarding.InitializationErrorScreen
|
||||||
import com.bitchat.android.onboarding.InitializingScreen
|
import com.bitchat.android.onboarding.InitializingScreen
|
||||||
import com.bitchat.android.onboarding.LocationCheckScreen
|
import com.bitchat.android.onboarding.LocationCheckScreen
|
||||||
@ -36,40 +41,91 @@ import com.bitchat.android.onboarding.PermissionExplanationScreen
|
|||||||
import com.bitchat.android.onboarding.PermissionManager
|
import com.bitchat.android.onboarding.PermissionManager
|
||||||
import com.bitchat.android.ui.ChatScreen
|
import com.bitchat.android.ui.ChatScreen
|
||||||
import com.bitchat.android.ui.ChatViewModel
|
import com.bitchat.android.ui.ChatViewModel
|
||||||
|
import com.bitchat.android.ui.OrientationAwareActivity
|
||||||
import com.bitchat.android.ui.theme.BitchatTheme
|
import com.bitchat.android.ui.theme.BitchatTheme
|
||||||
|
import com.bitchat.android.wifiaware.WifiAwareController
|
||||||
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
|
import com.bitchat.android.services.VerificationService
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : OrientationAwareActivity() {
|
||||||
|
|
||||||
private lateinit var permissionManager: PermissionManager
|
private lateinit var permissionManager: PermissionManager
|
||||||
private lateinit var onboardingCoordinator: OnboardingCoordinator
|
private lateinit var onboardingCoordinator: OnboardingCoordinator
|
||||||
private lateinit var bluetoothStatusManager: BluetoothStatusManager
|
private lateinit var bluetoothStatusManager: BluetoothStatusManager
|
||||||
private lateinit var locationStatusManager: LocationStatusManager
|
private lateinit var locationStatusManager: LocationStatusManager
|
||||||
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
|
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
|
||||||
|
|
||||||
// Core mesh service - managed at app level
|
// Core mesh service - provided by the foreground service holder
|
||||||
private lateinit var meshService: BluetoothMeshService
|
private lateinit var meshService: BluetoothMeshService
|
||||||
|
private lateinit var unifiedMeshService: MeshService
|
||||||
private val mainViewModel: MainViewModel by viewModels()
|
private val mainViewModel: MainViewModel by viewModels()
|
||||||
|
private var pendingMeshForegroundServiceStart = false
|
||||||
private val chatViewModel: ChatViewModel by viewModels {
|
private val chatViewModel: ChatViewModel by viewModels {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return ChatViewModel(application, meshService) as T
|
return ChatViewModel(application, meshService, unifiedMeshService) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
|
||||||
|
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
|
||||||
|
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
|
||||||
|
finishAffinity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
this.setRecentsScreenshotEnabled(false)
|
||||||
|
}
|
||||||
|
// Register receiver for force finish signal from shutdown coordinator
|
||||||
|
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||||
|
registerReceiver(
|
||||||
|
forceFinishReceiver,
|
||||||
|
filter,
|
||||||
|
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
|
||||||
|
null,
|
||||||
|
android.content.Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
registerReceiver(
|
||||||
|
forceFinishReceiver,
|
||||||
|
filter,
|
||||||
|
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize core mesh service first
|
// Check if this is a quit request from the notification
|
||||||
meshService = BluetoothMeshService(this)
|
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
|
||||||
|
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
|
||||||
|
|
||||||
|
// Enable edge-to-edge display for modern Android look
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Initialize permission management
|
// Initialize permission management
|
||||||
permissionManager = PermissionManager(this)
|
permissionManager = PermissionManager(this)
|
||||||
|
// Start the foreground service when allowed, then get mesh instances from the holder.
|
||||||
|
startMeshForegroundServiceBestEffort()
|
||||||
|
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
|
||||||
|
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||||
|
// Expose BLE mesh to Wi‑Fi Aware controller for cross-transport relays - DEPRECATED
|
||||||
|
// Bridging is now handled by TransportBridgeService automatically
|
||||||
|
|
||||||
bluetoothStatusManager = BluetoothStatusManager(
|
bluetoothStatusManager = BluetoothStatusManager(
|
||||||
activity = this,
|
activity = this,
|
||||||
context = this,
|
context = this,
|
||||||
@ -92,16 +148,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
activity = this,
|
activity = this,
|
||||||
permissionManager = permissionManager,
|
permissionManager = permissionManager,
|
||||||
onOnboardingComplete = ::handleOnboardingComplete,
|
onOnboardingComplete = ::handleOnboardingComplete,
|
||||||
|
onBackgroundLocationRequired = {
|
||||||
|
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||||
|
},
|
||||||
onOnboardingFailed = ::handleOnboardingFailed
|
onOnboardingFailed = ::handleOnboardingFailed
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
BitchatTheme {
|
BitchatTheme {
|
||||||
Surface(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
containerColor = MaterialTheme.colorScheme.background
|
||||||
) {
|
) { innerPadding ->
|
||||||
OnboardingFlowScreen()
|
OnboardingFlowScreen(modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,6 +176,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
WifiAwareController.running.collect { running ->
|
||||||
|
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||||
|
unifiedMeshService.delegate = chatViewModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only start onboarding process if we're in the initial CHECKING state
|
// Only start onboarding process if we're in the initial CHECKING state
|
||||||
// This prevents restarting onboarding on configuration changes
|
// This prevents restarting onboarding on configuration changes
|
||||||
@ -123,7 +196,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OnboardingFlowScreen() {
|
private fun OnboardingFlowScreen(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
val onboardingState by mainViewModel.onboardingState.collectAsState()
|
val onboardingState by mainViewModel.onboardingState.collectAsState()
|
||||||
val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState()
|
val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState()
|
||||||
val locationStatus by mainViewModel.locationStatus.collectAsState()
|
val locationStatus by mainViewModel.locationStatus.collectAsState()
|
||||||
@ -132,14 +206,36 @@ class MainActivity : ComponentActivity() {
|
|||||||
val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState()
|
val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState()
|
||||||
val isLocationLoading by mainViewModel.isLocationLoading.collectAsState()
|
val isLocationLoading by mainViewModel.isLocationLoading.collectAsState()
|
||||||
val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState()
|
val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState()
|
||||||
|
|
||||||
|
DisposableEffect(context, bluetoothStatusManager) {
|
||||||
|
|
||||||
|
val receiver = bluetoothStatusManager.monitorBluetoothState(
|
||||||
|
context = context,
|
||||||
|
bluetoothStatusManager = bluetoothStatusManager,
|
||||||
|
onBluetoothStateChanged = { status ->
|
||||||
|
if (status == BluetoothStatus.ENABLED && onboardingState == OnboardingState.BLUETOOTH_CHECK) {
|
||||||
|
checkBluetoothAndProceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(receiver)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
Log.w("BluetoothStatusUI", "Receiver was not registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (onboardingState) {
|
when (onboardingState) {
|
||||||
OnboardingState.CHECKING -> {
|
OnboardingState.PERMISSION_REQUESTING -> {
|
||||||
InitializingScreen()
|
InitializingScreen(modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.BLUETOOTH_CHECK -> {
|
OnboardingState.BLUETOOTH_CHECK -> {
|
||||||
BluetoothCheckScreen(
|
BluetoothCheckScreen(
|
||||||
|
modifier = modifier,
|
||||||
status = bluetoothStatus,
|
status = bluetoothStatus,
|
||||||
onEnableBluetooth = {
|
onEnableBluetooth = {
|
||||||
mainViewModel.updateBluetoothLoading(true)
|
mainViewModel.updateBluetoothLoading(true)
|
||||||
@ -148,12 +244,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
onRetry = {
|
onRetry = {
|
||||||
checkBluetoothAndProceed()
|
checkBluetoothAndProceed()
|
||||||
},
|
},
|
||||||
|
onSkip = {
|
||||||
|
mainViewModel.skipBluetoothCheck()
|
||||||
|
checkLocationAndProceed()
|
||||||
|
},
|
||||||
isLoading = isBluetoothLoading
|
isLoading = isBluetoothLoading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.LOCATION_CHECK -> {
|
OnboardingState.LOCATION_CHECK -> {
|
||||||
LocationCheckScreen(
|
LocationCheckScreen(
|
||||||
|
modifier = modifier,
|
||||||
status = locationStatus,
|
status = locationStatus,
|
||||||
onEnableLocation = {
|
onEnableLocation = {
|
||||||
mainViewModel.updateLocationLoading(true)
|
mainViewModel.updateLocationLoading(true)
|
||||||
@ -168,6 +269,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
OnboardingState.BATTERY_OPTIMIZATION_CHECK -> {
|
OnboardingState.BATTERY_OPTIMIZATION_CHECK -> {
|
||||||
BatteryOptimizationScreen(
|
BatteryOptimizationScreen(
|
||||||
|
modifier = modifier,
|
||||||
status = batteryOptimizationStatus,
|
status = batteryOptimizationStatus,
|
||||||
onDisableBatteryOptimization = {
|
onDisableBatteryOptimization = {
|
||||||
mainViewModel.updateBatteryOptimizationLoading(true)
|
mainViewModel.updateBatteryOptimizationLoading(true)
|
||||||
@ -186,6 +288,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
OnboardingState.PERMISSION_EXPLANATION -> {
|
OnboardingState.PERMISSION_EXPLANATION -> {
|
||||||
PermissionExplanationScreen(
|
PermissionExplanationScreen(
|
||||||
|
modifier = modifier,
|
||||||
permissionCategories = permissionManager.getCategorizedPermissions(),
|
permissionCategories = permissionManager.getCategorizedPermissions(),
|
||||||
onContinue = {
|
onContinue = {
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING)
|
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING)
|
||||||
@ -193,23 +296,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.PERMISSION_REQUESTING -> {
|
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
|
||||||
InitializingScreen()
|
BackgroundLocationPermissionScreen(
|
||||||
|
modifier = modifier,
|
||||||
|
onContinue = {
|
||||||
|
onboardingCoordinator.requestBackgroundLocation()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
onboardingCoordinator.checkBackgroundLocationAndProceed()
|
||||||
|
},
|
||||||
|
onSkip = {
|
||||||
|
onboardingCoordinator.skipBackgroundLocation()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.INITIALIZING -> {
|
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
|
||||||
InitializingScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
OnboardingState.COMPLETE -> {
|
|
||||||
// Set up back navigation handling for the chat screen
|
// Set up back navigation handling for the chat screen
|
||||||
val backCallback = object : OnBackPressedCallback(true) {
|
val backCallback = object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
// Let ChatViewModel handle navigation state
|
// Let ChatViewModel handle navigation state
|
||||||
val handled = chatViewModel.handleBackPressed()
|
val handled = chatViewModel.handleBackPressed()
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
// If ChatViewModel doesn't handle it, disable this callback
|
// If ChatViewModel doesn't handle it, disable this callback
|
||||||
// and let the system handle it (which will exit the app)
|
// and let the system handle it (which will exit the app)
|
||||||
this.isEnabled = false
|
this.isEnabled = false
|
||||||
onBackPressedDispatcher.onBackPressed()
|
onBackPressedDispatcher.onBackPressed()
|
||||||
@ -217,15 +327,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the callback - this will be automatically removed when the activity is destroyed
|
// Add the callback - this will be automatically removed when the activity is destroyed
|
||||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||||
|
|
||||||
ChatScreen(viewModel = chatViewModel)
|
ChatScreen(viewModel = chatViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.ERROR -> {
|
OnboardingState.ERROR -> {
|
||||||
InitializationErrorScreen(
|
InitializationErrorScreen(
|
||||||
|
modifier = modifier,
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
onRetry = {
|
onRetry = {
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.CHECKING)
|
mainViewModel.updateOnboardingState(OnboardingState.CHECKING)
|
||||||
@ -244,7 +354,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
when (state) {
|
when (state) {
|
||||||
OnboardingState.COMPLETE -> {
|
OnboardingState.COMPLETE -> {
|
||||||
// App is fully initialized, mesh service is running
|
// App is fully initialized, mesh service is running
|
||||||
android.util.Log.d("MainActivity", "Onboarding completed - app ready")
|
android.util.Log.i("MainActivity", "Onboarding completed - app ready")
|
||||||
}
|
}
|
||||||
OnboardingState.ERROR -> {
|
OnboardingState.ERROR -> {
|
||||||
android.util.Log.e("MainActivity", "Onboarding error state reached")
|
android.util.Log.e("MainActivity", "Onboarding error state reached")
|
||||||
@ -254,8 +364,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkOnboardingStatus() {
|
private fun checkOnboardingStatus() {
|
||||||
Log.d("MainActivity", "Checking onboarding status")
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
// Small delay to show the checking state
|
// Small delay to show the checking state
|
||||||
delay(500)
|
delay(500)
|
||||||
@ -269,12 +377,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Check Bluetooth status and proceed with onboarding flow
|
* Check Bluetooth status and proceed with onboarding flow
|
||||||
*/
|
*/
|
||||||
private fun checkBluetoothAndProceed() {
|
private fun checkBluetoothAndProceed() {
|
||||||
// Log.d("MainActivity", "Checking Bluetooth status")
|
// Check if user has skipped Bluetooth check for this session
|
||||||
|
if (mainViewModel.isBluetoothCheckSkipped.value) {
|
||||||
|
checkLocationAndProceed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// For first-time users, skip Bluetooth check and go straight to permissions
|
// For first-time users, skip Bluetooth check and go straight to permissions
|
||||||
// We'll check Bluetooth after permissions are granted
|
// We'll check Bluetooth after permissions are granted
|
||||||
if (permissionManager.isFirstTimeLaunch()) {
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
|
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -283,6 +394,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
bluetoothStatusManager.logBluetoothStatus()
|
bluetoothStatusManager.logBluetoothStatus()
|
||||||
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
|
||||||
|
|
||||||
|
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||||
|
if (!bleRequired) {
|
||||||
|
// Skip BLE checks entirely when BLE is disabled in debug settings
|
||||||
|
checkLocationAndProceed()
|
||||||
|
return
|
||||||
|
}
|
||||||
when (mainViewModel.bluetoothStatus.value) {
|
when (mainViewModel.bluetoothStatus.value) {
|
||||||
BluetoothStatus.ENABLED -> {
|
BluetoothStatus.ENABLED -> {
|
||||||
// Bluetooth is enabled, check location services next
|
// Bluetooth is enabled, check location services next
|
||||||
@ -290,7 +407,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
BluetoothStatus.DISABLED -> {
|
BluetoothStatus.DISABLED -> {
|
||||||
// Show Bluetooth enable screen (should have permissions as existing user)
|
// Show Bluetooth enable screen (should have permissions as existing user)
|
||||||
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||||
mainViewModel.updateBluetoothLoading(false)
|
mainViewModel.updateBluetoothLoading(false)
|
||||||
}
|
}
|
||||||
@ -307,20 +423,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Proceed with permission checking
|
* Proceed with permission checking
|
||||||
*/
|
*/
|
||||||
private fun proceedWithPermissionCheck() {
|
private fun proceedWithPermissionCheck() {
|
||||||
Log.d("MainActivity", "Proceeding with permission check")
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
delay(200) // Small delay for smooth transition
|
delay(200) // Small delay for smooth transition
|
||||||
|
|
||||||
if (permissionManager.isFirstTimeLaunch()) {
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||||
} else if (permissionManager.areAllPermissionsGranted()) {
|
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
|
||||||
Log.d("MainActivity", "Existing user with permissions, initializing app")
|
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||||
initializeApp()
|
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||||
|
!permissionManager.isBackgroundLocationGranted() &&
|
||||||
|
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
|
||||||
|
) {
|
||||||
|
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
|
||||||
|
} else {
|
||||||
|
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||||
|
initializeApp()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -330,7 +450,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Handle Bluetooth enabled callback
|
* Handle Bluetooth enabled callback
|
||||||
*/
|
*/
|
||||||
private fun handleBluetoothEnabled() {
|
private fun handleBluetoothEnabled() {
|
||||||
Log.d("MainActivity", "Bluetooth enabled by user")
|
|
||||||
mainViewModel.updateBluetoothLoading(false)
|
mainViewModel.updateBluetoothLoading(false)
|
||||||
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
|
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
|
||||||
checkLocationAndProceed()
|
checkLocationAndProceed()
|
||||||
@ -340,12 +459,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Check Location services status and proceed with onboarding flow
|
* Check Location services status and proceed with onboarding flow
|
||||||
*/
|
*/
|
||||||
private fun checkLocationAndProceed() {
|
private fun checkLocationAndProceed() {
|
||||||
Log.d("MainActivity", "Checking location services status")
|
|
||||||
|
|
||||||
// For first-time users, skip location check and go straight to permissions
|
// For first-time users, skip location check and go straight to permissions
|
||||||
// We'll check location after permissions are granted
|
// We'll check location after permissions are granted
|
||||||
if (permissionManager.isFirstTimeLaunch()) {
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
|
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -361,7 +477,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
LocationStatus.DISABLED -> {
|
LocationStatus.DISABLED -> {
|
||||||
// Show location enable screen (should have permissions as existing user)
|
// Show location enable screen (should have permissions as existing user)
|
||||||
Log.d("MainActivity", "Location services disabled, showing enable screen")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||||
mainViewModel.updateLocationLoading(false)
|
mainViewModel.updateLocationLoading(false)
|
||||||
}
|
}
|
||||||
@ -378,9 +493,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Handle Location enabled callback
|
* Handle Location enabled callback
|
||||||
*/
|
*/
|
||||||
private fun handleLocationEnabled() {
|
private fun handleLocationEnabled() {
|
||||||
Log.d("MainActivity", "Location services enabled by user")
|
|
||||||
mainViewModel.updateLocationLoading(false)
|
mainViewModel.updateLocationLoading(false)
|
||||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||||
|
// Ensure Wi-Fi Aware starts now that location is enabled
|
||||||
|
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||||
checkBatteryOptimizationAndProceed()
|
checkBatteryOptimizationAndProceed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -422,12 +538,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
|
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
|
||||||
// During first-time onboarding, if Bluetooth enable fails due to permissions,
|
// During first-time onboarding, if Bluetooth enable fails due to permissions,
|
||||||
// proceed to permission explanation screen where user will grant permissions first
|
// proceed to permission explanation screen where user will grant permissions first
|
||||||
Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
|
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
}
|
}
|
||||||
message.contains("Permission") -> {
|
message.contains("Permission") -> {
|
||||||
// For existing users, redirect to permission explanation to grant missing permissions
|
// For existing users, redirect to permission explanation to grant missing permissions
|
||||||
Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@ -438,8 +552,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOnboardingComplete() {
|
private fun handleOnboardingComplete() {
|
||||||
Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
|
|
||||||
|
|
||||||
// After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
|
// After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
|
||||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||||
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
||||||
@ -449,31 +561,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
else -> BatteryOptimizationStatus.ENABLED
|
else -> BatteryOptimizationStatus.ENABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||||
when {
|
when {
|
||||||
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||||
// Bluetooth still disabled, but now we have permissions to enable it
|
// Bluetooth still disabled, but now we have permissions to enable it
|
||||||
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
|
||||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||||
mainViewModel.updateBluetoothLoading(false)
|
mainViewModel.updateBluetoothLoading(false)
|
||||||
}
|
}
|
||||||
currentLocationStatus != LocationStatus.ENABLED -> {
|
currentLocationStatus != LocationStatus.ENABLED -> {
|
||||||
// Location services still disabled, but now we have permissions to enable it
|
// Location services still disabled, but now we have permissions to enable it
|
||||||
Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
|
|
||||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||||
mainViewModel.updateLocationLoading(false)
|
mainViewModel.updateLocationLoading(false)
|
||||||
}
|
}
|
||||||
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
|
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
|
||||||
// Battery optimization still enabled, show battery optimization screen
|
// Battery optimization still enabled, show battery optimization screen
|
||||||
android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.")
|
|
||||||
mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
|
mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
||||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Both are enabled, proceed to app initialization
|
// Both are enabled, proceed to app initialization
|
||||||
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||||
initializeApp()
|
initializeApp()
|
||||||
}
|
}
|
||||||
@ -485,17 +594,36 @@ class MainActivity : ComponentActivity() {
|
|||||||
mainViewModel.updateErrorMessage(message)
|
mainViewModel.updateErrorMessage(message)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.ERROR)
|
mainViewModel.updateOnboardingState(OnboardingState.ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startMeshForegroundServiceBestEffort() {
|
||||||
|
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
pendingMeshForegroundServiceStart = true
|
||||||
|
Log.i("MainActivity", "Deferring foreground mesh service start until activity is started")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
com.bitchat.android.service.MeshForegroundService.start(applicationContext)
|
||||||
|
pendingMeshForegroundServiceStart = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingMeshForegroundServiceStart = true
|
||||||
|
Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check Battery Optimization status and proceed with onboarding flow
|
* Check Battery Optimization status and proceed with onboarding flow
|
||||||
*/
|
*/
|
||||||
private fun checkBatteryOptimizationAndProceed() {
|
private fun checkBatteryOptimizationAndProceed() {
|
||||||
android.util.Log.d("MainActivity", "Checking battery optimization status")
|
|
||||||
|
|
||||||
// For first-time users, skip battery optimization check and go straight to permissions
|
// For first-time users, skip battery optimization check and go straight to permissions
|
||||||
// We'll check battery optimization after permissions are granted
|
// We'll check battery optimization after permissions are granted
|
||||||
if (permissionManager.isFirstTimeLaunch()) {
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions")
|
proceedWithPermissionCheck()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has previously skipped battery optimization
|
||||||
|
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -516,7 +644,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
BatteryOptimizationStatus.ENABLED -> {
|
BatteryOptimizationStatus.ENABLED -> {
|
||||||
// Show battery optimization disable screen
|
// Show battery optimization disable screen
|
||||||
android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen")
|
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
||||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||||
}
|
}
|
||||||
@ -527,7 +654,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Handle Battery Optimization disabled callback
|
* Handle Battery Optimization disabled callback
|
||||||
*/
|
*/
|
||||||
private fun handleBatteryOptimizationDisabled() {
|
private fun handleBatteryOptimizationDisabled() {
|
||||||
android.util.Log.d("MainActivity", "Battery optimization disabled by user")
|
|
||||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||||
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
|
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
@ -551,15 +677,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeApp() {
|
private fun initializeApp() {
|
||||||
Log.d("MainActivity", "Starting app initialization")
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
|
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
|
||||||
// This solves the issue where app needs restart to work on first install
|
// This solves the issue where app needs restart to work on first install
|
||||||
delay(1000) // Give the system time to process permission grants
|
delay(1000) // Give the system time to process permission grants
|
||||||
|
|
||||||
|
// Initialize PoW preferences early in the initialization process
|
||||||
|
PoWPreferenceManager.init(this@MainActivity)
|
||||||
|
|
||||||
Log.d("MainActivity", "Permissions verified, initializing chat system")
|
// Initialize Location Notes Manager (extracted to separate file)
|
||||||
|
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
|
||||||
|
|
||||||
// Ensure all permissions are still granted (user might have revoked in settings)
|
// Ensure all permissions are still granted (user might have revoked in settings)
|
||||||
if (!permissionManager.areAllPermissionsGranted()) {
|
if (!permissionManager.areAllPermissionsGranted()) {
|
||||||
@ -568,19 +696,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.")
|
handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up mesh service delegate and start services
|
// Set up unified mesh delegate and start enabled transports
|
||||||
meshService.delegate = chatViewModel
|
unifiedMeshService.delegate = chatViewModel
|
||||||
meshService.startServices()
|
unifiedMeshService.startServices()
|
||||||
|
startMeshForegroundServiceBestEffort()
|
||||||
Log.d("MainActivity", "Mesh service started successfully")
|
|
||||||
|
|
||||||
// Handle any notification intent
|
// Handle any notification intent
|
||||||
handleNotificationIntent(intent)
|
handleNotificationIntent(intent)
|
||||||
|
handleVerificationIntent(intent)
|
||||||
|
|
||||||
// Small delay to ensure mesh service is fully initialized
|
// Small delay to ensure mesh service is fully initialized
|
||||||
delay(500)
|
delay(500)
|
||||||
Log.d("MainActivity", "App initialization complete")
|
Log.i("MainActivity", "App initialization complete")
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
|
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("MainActivity", "Failed to initialize app", e)
|
Log.e("MainActivity", "Failed to initialize app", e)
|
||||||
@ -591,23 +719,45 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
|
||||||
|
// Check if this is a quit request from the notification
|
||||||
|
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
|
||||||
|
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
|
||||||
|
|
||||||
// Handle notification intents when app is already running
|
// Handle notification intents when app is already running
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
handleNotificationIntent(intent)
|
handleNotificationIntent(intent)
|
||||||
|
handleVerificationIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
if (pendingMeshForegroundServiceStart) {
|
||||||
|
startMeshForegroundServiceBestEffort()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
// Revoke stale live-location work before any resumed UI can use cached channels.
|
||||||
|
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
|
||||||
|
|
||||||
// Check Bluetooth and Location status on resume and handle accordingly
|
// Check Bluetooth and Location status on resume and handle accordingly
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
// Set app foreground state
|
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
|
||||||
meshService.connectionManager.setAppBackgroundState(false)
|
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
|
||||||
chatViewModel.setAppBackgroundState(false)
|
|
||||||
|
|
||||||
// Check if Bluetooth was disabled while app was backgrounded
|
// Check if Bluetooth was disabled while app was backgrounded
|
||||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||||
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
|
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
|
||||||
|
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
|
||||||
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
|
||||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||||
@ -622,6 +772,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||||
mainViewModel.updateLocationLoading(false)
|
mainViewModel.updateLocationLoading(false)
|
||||||
|
} else {
|
||||||
|
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
|
||||||
|
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -630,14 +783,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onPause()
|
super.onPause()
|
||||||
// Only set background state if app is fully initialized
|
// Only set background state if app is fully initialized
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
||||||
// Set app background state
|
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
|
||||||
meshService.connectionManager.setAppBackgroundState(true)
|
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
|
||||||
chatViewModel.setAppBackgroundState(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle intents from notification clicks - open specific private chat
|
* Handle intents from notification clicks - open specific private chat or geohash chat
|
||||||
*/
|
*/
|
||||||
private fun handleNotificationIntent(intent: Intent) {
|
private fun handleNotificationIntent(intent: Intent) {
|
||||||
val shouldOpenPrivateChat = intent.getBooleanExtra(
|
val shouldOpenPrivateChat = intent.getBooleanExtra(
|
||||||
@ -645,41 +797,81 @@ class MainActivity : ComponentActivity() {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
if (shouldOpenPrivateChat) {
|
val shouldOpenGeohashChat = intent.getBooleanExtra(
|
||||||
val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID)
|
com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_GEOHASH_CHAT,
|
||||||
val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME)
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
when {
|
||||||
|
shouldOpenPrivateChat -> {
|
||||||
|
val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID)
|
||||||
|
val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME)
|
||||||
|
|
||||||
|
if (peerID != null) {
|
||||||
|
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
||||||
|
|
||||||
|
// Open the private chat sheet with this peer
|
||||||
|
chatViewModel.showMeshPeerList()
|
||||||
|
chatViewModel.showPrivateChatSheet(peerID)
|
||||||
|
|
||||||
|
// Clear notifications for this sender since user is now viewing the chat
|
||||||
|
chatViewModel.clearNotificationsForSender(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (peerID != null) {
|
shouldOpenGeohashChat -> {
|
||||||
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
|
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
|
||||||
|
|
||||||
// Open the private chat with this peer
|
if (geohash != null) {
|
||||||
chatViewModel.startPrivateChat(peerID)
|
Log.d("MainActivity", "Opening geohash chat from notification")
|
||||||
|
|
||||||
// Clear notifications for this sender since user is now viewing the chat
|
// Switch to the geohash channel - create appropriate geohash channel level
|
||||||
chatViewModel.clearNotificationsForSender(peerID)
|
val level = when (geohash.length) {
|
||||||
|
7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||||
|
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||||
|
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||||
|
4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||||
|
2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||||
|
else -> com.bitchat.android.geohash.GeohashChannelLevel.CITY // Default fallback
|
||||||
|
}
|
||||||
|
val geohashChannel = com.bitchat.android.geohash.GeohashChannel(level, geohash)
|
||||||
|
val channelId = com.bitchat.android.geohash.ChannelID.Location(geohashChannel)
|
||||||
|
chatViewModel.selectLocationChannel(channelId)
|
||||||
|
|
||||||
|
// Update current geohash state for notifications
|
||||||
|
chatViewModel.setCurrentGeohash(geohash)
|
||||||
|
|
||||||
|
// Clear notifications for this geohash since user is now viewing it
|
||||||
|
chatViewModel.clearNotificationsForGeohash(geohash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleVerificationIntent(intent: Intent) {
|
||||||
|
val uri = intent.data ?: return
|
||||||
|
if (uri.scheme != "bitchat" || uri.host != "verify") return
|
||||||
|
|
||||||
|
chatViewModel.showVerificationSheet()
|
||||||
|
val qr = VerificationService.verifyScannedQR(uri.toString())
|
||||||
|
if (qr != null) {
|
||||||
|
chatViewModel.beginQRVerification(qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
|
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
|
||||||
|
|
||||||
// Cleanup location status manager
|
// Cleanup location status manager
|
||||||
try {
|
try {
|
||||||
locationStatusManager.cleanup()
|
locationStatusManager.cleanup()
|
||||||
Log.d("MainActivity", "Location status manager cleaned up successfully")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop mesh services if app was fully initialized
|
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability
|
||||||
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
|
|
||||||
try {
|
|
||||||
meshService.stopServices()
|
|
||||||
Log.d("MainActivity", "Mesh services stopped successfully")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
|
|||||||
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
|
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
|
||||||
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
|
||||||
|
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
|
||||||
|
|
||||||
// Public update functions for MainActivity
|
// Public update functions for MainActivity
|
||||||
fun updateOnboardingState(state: OnboardingState) {
|
fun updateOnboardingState(state: OnboardingState) {
|
||||||
_onboardingState.value = state
|
_onboardingState.value = state
|
||||||
@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
|
|||||||
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
fun updateBatteryOptimizationLoading(loading: Boolean) {
|
||||||
_isBatteryOptimizationLoading.value = loading
|
_isBatteryOptimizationLoading.value = loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipBluetoothCheck() {
|
||||||
|
_isBluetoothCheckSkipped.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.button
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||||
|
import com.bitchat.android.ui.rememberPressScale
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
private val MultiClickThreshold = 300.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BitChatBrandButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onTripleClick: () -> Unit,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tint: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
iconSize: Dp = 22.dp,
|
||||||
|
) {
|
||||||
|
var tapCount by remember { mutableIntStateOf(0) }
|
||||||
|
var resetJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val currentOnClick by rememberUpdatedState(onClick)
|
||||||
|
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val pressScale = rememberPressScale(interactionSource)
|
||||||
|
|
||||||
|
// A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the
|
||||||
|
// only press background left in the header once every other control moved to scale-only
|
||||||
|
// feedback.
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null,
|
||||||
|
onClickLabel = contentDescription
|
||||||
|
) {
|
||||||
|
tapCount += 1
|
||||||
|
resetJob?.cancel()
|
||||||
|
|
||||||
|
if (tapCount == 3) {
|
||||||
|
tapCount = 0
|
||||||
|
resetJob = null
|
||||||
|
currentOnTripleClick()
|
||||||
|
} else {
|
||||||
|
resetJob = coroutineScope.launch {
|
||||||
|
delay(MultiClickThreshold)
|
||||||
|
if (tapCount == 1) {
|
||||||
|
currentOnClick()
|
||||||
|
}
|
||||||
|
tapCount = 0
|
||||||
|
resetJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = BitChatIcon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(iconSize)
|
||||||
|
.scale(pressScale),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.button
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitchat.android.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CloseButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
// 44.dp to match every other tap target in the app's chrome.
|
||||||
|
modifier = modifier.size(44.dp),
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
contentColor = colorScheme.primary,
|
||||||
|
containerColor = Color.Transparent
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.close_plain),
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.sheet
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first.
|
||||||
|
*
|
||||||
|
* `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap
|
||||||
|
* on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from
|
||||||
|
* a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the
|
||||||
|
* composable out of the tree and makes the sheet vanish instantly.
|
||||||
|
*
|
||||||
|
* Anything inside a sheet that wants to close it should prefer this over calling its own
|
||||||
|
* `onDismiss` directly.
|
||||||
|
*/
|
||||||
|
val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BitchatBottomSheet(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
content: @Composable (ColumnScope.() -> Unit),
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()`
|
||||||
|
// throws if the sheet is already on its way out (two rapid taps on a close button), which is
|
||||||
|
// benign — the dismissal still has to go through.
|
||||||
|
val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) {
|
||||||
|
{
|
||||||
|
scope.launch {
|
||||||
|
runCatching { sheetState.hide() }
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
modifier = modifier.statusBarsPadding(),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.sheet
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||||
|
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BitchatSheetTopBar(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
backgroundAlpha: Float = 0.98f,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
title = title,
|
||||||
|
navigationIcon = { navigationIcon?.invoke() },
|
||||||
|
actions = {
|
||||||
|
actions()
|
||||||
|
val dismiss = LocalSheetDismiss.current
|
||||||
|
CloseButton(
|
||||||
|
onClick = { dismiss?.invoke() ?: onClose() },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BitchatSheetCenterTopBar(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
backgroundAlpha: Float = 0.98f,
|
||||||
|
title: @Composable () -> Unit,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {}
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = title,
|
||||||
|
navigationIcon = { navigationIcon?.invoke() },
|
||||||
|
actions = {
|
||||||
|
actions()
|
||||||
|
val dismiss = LocalSheetDismiss.current
|
||||||
|
CloseButton(
|
||||||
|
onClick = { dismiss?.invoke() ?: onClose() },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BitchatSheetTitle(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = BitchatFontFamily
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package com.bitchat.android.core.ui.component.text
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
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.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
|
internal data class ClickedAnnotation(
|
||||||
|
val tag: String,
|
||||||
|
val item: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun findAnnotationAt(
|
||||||
|
text: AnnotatedString,
|
||||||
|
offset: Int,
|
||||||
|
annotationTags: List<String>,
|
||||||
|
): ClickedAnnotation? {
|
||||||
|
for (tag in annotationTags) {
|
||||||
|
text.getStringAnnotations(tag = tag, start = offset, end = offset)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { annotation ->
|
||||||
|
return ClickedAnnotation(tag = tag, item = annotation.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AnnotatedClickableText(
|
||||||
|
text: AnnotatedString,
|
||||||
|
annotationTags: List<String>,
|
||||||
|
onAnnotationClick: (tag: String, item: String) -> Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onLongPress: (() -> Unit)? = null,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
softWrap: Boolean = true,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
style: TextStyle = LocalTextStyle.current,
|
||||||
|
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
|
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
|
||||||
|
val currentOnLongPress by rememberUpdatedState(onLongPress)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = modifier.pointerInput(text, annotationTags, onLongPress != null) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { position ->
|
||||||
|
val offset = layoutResult
|
||||||
|
?.getOffsetForPosition(position)
|
||||||
|
?: return@detectTapGestures
|
||||||
|
|
||||||
|
var remainingTags = annotationTags
|
||||||
|
while (remainingTags.isNotEmpty()) {
|
||||||
|
val annotation = findAnnotationAt(
|
||||||
|
text = text,
|
||||||
|
offset = offset,
|
||||||
|
annotationTags = remainingTags,
|
||||||
|
) ?: break
|
||||||
|
if (currentOnAnnotationClick(annotation.tag, annotation.item)) {
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
|
remainingTags = remainingTags.drop(
|
||||||
|
remainingTags.indexOf(annotation.tag) + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress = currentOnLongPress?.let { callback ->
|
||||||
|
{ callback() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = color,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
softWrap = softWrap,
|
||||||
|
overflow = overflow,
|
||||||
|
style = style,
|
||||||
|
onTextLayout = { result ->
|
||||||
|
layoutResult = result
|
||||||
|
onTextLayout?.invoke(result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.bitchat.android.core.ui.icon
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val BitChatIcon: ImageVector
|
||||||
|
get() {
|
||||||
|
_BitChatIcon?.let { return it }
|
||||||
|
|
||||||
|
return ImageVector.Builder(
|
||||||
|
name = "BitChatIcon",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 8f,
|
||||||
|
viewportHeight = 8f,
|
||||||
|
).apply {
|
||||||
|
path(fill = SolidColor(Color.Black)) {
|
||||||
|
moveTo(2f, 0f)
|
||||||
|
lineTo(6f, 0f)
|
||||||
|
lineTo(6f, 1f)
|
||||||
|
lineTo(7f, 1f)
|
||||||
|
lineTo(7f, 2f)
|
||||||
|
lineTo(8f, 2f)
|
||||||
|
lineTo(8f, 5f)
|
||||||
|
lineTo(7f, 5f)
|
||||||
|
lineTo(7f, 6f)
|
||||||
|
lineTo(6f, 6f)
|
||||||
|
lineTo(6f, 8f)
|
||||||
|
lineTo(5f, 8f)
|
||||||
|
lineTo(5f, 7f)
|
||||||
|
lineTo(3f, 7f)
|
||||||
|
lineTo(3f, 6f)
|
||||||
|
lineTo(1f, 6f)
|
||||||
|
lineTo(1f, 5f)
|
||||||
|
lineTo(0f, 5f)
|
||||||
|
lineTo(0f, 2f)
|
||||||
|
lineTo(1f, 2f)
|
||||||
|
lineTo(1f, 1f)
|
||||||
|
lineTo(2f, 1f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}.build().also { _BitChatIcon = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _BitChatIcon: ImageVector? = null
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package com.bitchat.android.core.ui.utils
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.composed
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
fun Modifier.singleOrTripleClickable(
|
|
||||||
onSingleClick: () -> Unit,
|
|
||||||
onTripleClick: () -> Unit,
|
|
||||||
clickTimeThreshold: Long = 300L
|
|
||||||
): Modifier = composed {
|
|
||||||
var tapCount by remember { mutableIntStateOf(0) }
|
|
||||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
|
||||||
var singleClickJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
this.clickable {
|
|
||||||
val currentTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
if (currentTime - lastTapTime < clickTimeThreshold) {
|
|
||||||
tapCount++
|
|
||||||
} else {
|
|
||||||
tapCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTapTime = currentTime
|
|
||||||
|
|
||||||
// Cancel any pending single click action
|
|
||||||
singleClickJob?.cancel()
|
|
||||||
singleClickJob = null
|
|
||||||
|
|
||||||
when (tapCount) {
|
|
||||||
1 -> {
|
|
||||||
// Wait to see if more taps come
|
|
||||||
singleClickJob = coroutineScope.launch {
|
|
||||||
delay(clickTimeThreshold)
|
|
||||||
if (tapCount == 1) {
|
|
||||||
onSingleClick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3 -> {
|
|
||||||
// Triple click detected - execute immediately
|
|
||||||
onTripleClick()
|
|
||||||
tapCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset after threshold if no triple click
|
|
||||||
if (tapCount > 3) {
|
|
||||||
tapCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +1,24 @@
|
|||||||
package com.bitchat.android.crypto
|
package com.bitchat.android.crypto
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import com.bitchat.android.noise.NoiseEncryptionService
|
import com.bitchat.android.noise.NoiseEncryptionService
|
||||||
|
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
|
||||||
|
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||||
|
import com.bitchat.android.noise.NoiseDecryptionResult
|
||||||
|
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||||
|
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
|
||||||
|
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
|
||||||
|
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||||
|
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||||
|
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import androidx.core.content.edit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encryption service that now uses NoiseEncryptionService internally
|
* Encryption service that now uses NoiseEncryptionService internally
|
||||||
@ -13,24 +27,62 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* This is the main interface for all encryption/decryption operations in bitchat.
|
* This is the main interface for all encryption/decryption operations in bitchat.
|
||||||
* It now uses the Noise protocol for secure transport encryption with proper session management.
|
* It now uses the Noise protocol for secure transport encryption with proper session management.
|
||||||
*/
|
*/
|
||||||
class EncryptionService(private val context: Context) {
|
open class EncryptionService(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "EncryptionService"
|
private const val TAG = "EncryptionService"
|
||||||
|
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
|
||||||
|
private const val OLD_PREFS_NAME = "bitchat_crypto"
|
||||||
|
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core Noise encryption service
|
// Core Noise encryption service
|
||||||
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
|
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
|
||||||
|
|
||||||
// Session tracking for established connections
|
// Session tracking for established connections
|
||||||
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
|
||||||
|
|
||||||
|
// Ed25519 signing keys (separate from Noise static keys)
|
||||||
|
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
|
||||||
|
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
|
||||||
|
|
||||||
// Callbacks for UI state updates
|
// Callbacks for UI state updates
|
||||||
var onSessionEstablished: ((String) -> Unit)? = null // peerID
|
var onSessionEstablished: ((String) -> Unit)? = null // peerID
|
||||||
var onSessionLost: ((String) -> Unit)? = null // peerID
|
var onSessionLost: ((String) -> Unit)? = null // peerID
|
||||||
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
|
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUpEncryptedPrefs() {
|
||||||
|
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Create encrypted shared preferences
|
||||||
|
prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
SECURE_PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialization logic moved to method to allow overriding in tests
|
||||||
|
*/
|
||||||
|
protected open fun initialize() {
|
||||||
|
setUpEncryptedPrefs()
|
||||||
|
// Initialize or load Ed25519 signing keys
|
||||||
|
val keyPair = loadOrCreateEd25519KeyPair()
|
||||||
|
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||||
|
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||||
|
|
||||||
|
Log.d(TAG, "✅ Ed25519 signing keys initialized")
|
||||||
|
|
||||||
// Set up NoiseEncryptionService callbacks
|
// Set up NoiseEncryptionService callbacks
|
||||||
noiseService.onPeerAuthenticated = { peerID, fingerprint ->
|
noiseService.onPeerAuthenticated = { peerID, fingerprint ->
|
||||||
Log.d(TAG, "✅ Noise session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
|
Log.d(TAG, "✅ Noise session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
|
||||||
@ -63,24 +115,26 @@ class EncryptionService(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get our signing public key for Ed25519 signatures (for identity announcements)
|
* Get our signing public key for Ed25519 signatures (for identity announcements)
|
||||||
* Note: In the current implementation, this returns the same as static key
|
|
||||||
* In a full implementation, this would be a separate Ed25519 key
|
|
||||||
*/
|
*/
|
||||||
fun getSigningPublicKey(): ByteArray? {
|
fun getSigningPublicKey(): ByteArray? {
|
||||||
// For now, return the static public key as placeholder
|
return ed25519PublicKey.encoded
|
||||||
// In a full implementation, this would be a separate Ed25519 signing key
|
|
||||||
return noiseService.getStaticPublicKeyData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign data using our signing key (for identity announcements)
|
* Sign data using our Ed25519 signing key (for identity announcements)
|
||||||
* Note: In the current simplified implementation, this returns empty signature
|
|
||||||
* In a full implementation, this would use Ed25519 signing
|
|
||||||
*/
|
*/
|
||||||
fun signData(data: ByteArray): ByteArray? {
|
fun signData(data: ByteArray): ByteArray? {
|
||||||
// For now, return empty signature as placeholder
|
return try {
|
||||||
// In a full implementation, this would use Ed25519 to sign the data
|
val signer = Ed25519Signer()
|
||||||
return ByteArray(64) // Ed25519 signature length placeholder
|
signer.init(true, ed25519PrivateKey)
|
||||||
|
signer.update(data, 0, data.size)
|
||||||
|
val signature = signer.generateSignature()
|
||||||
|
Log.d(TAG, "✅ Generated Ed25519 signature (${signature.size} bytes)")
|
||||||
|
signature
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Failed to sign data with Ed25519: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,6 +166,20 @@ class EncryptionService(private val context: Context) {
|
|||||||
fun clearPersistentIdentity() {
|
fun clearPersistentIdentity() {
|
||||||
noiseService.clearPersistentIdentity()
|
noiseService.clearPersistentIdentity()
|
||||||
establishedSessions.clear()
|
establishedSessions.clear()
|
||||||
|
|
||||||
|
// Clear Ed25519 signing key from preferences
|
||||||
|
try {
|
||||||
|
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) }
|
||||||
|
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
|
||||||
|
|
||||||
|
// Generate new keys immediately
|
||||||
|
val keyPair = loadOrCreateEd25519KeyPair()
|
||||||
|
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||||
|
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
|
||||||
|
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,6 +193,13 @@ class EncryptionService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return encrypted
|
return encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun encryptForSession(
|
||||||
|
data: ByteArray,
|
||||||
|
peerID: String,
|
||||||
|
expectedSession: AuthenticatedNoiseSession
|
||||||
|
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt data from a specific peer using Noise transport encryption
|
* Decrypt data from a specific peer using Noise transport encryption
|
||||||
@ -137,6 +212,12 @@ class EncryptionService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return decrypted
|
return decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
|
||||||
|
return noiseService.decryptWithSession(data, peerID)
|
||||||
|
?: throw Exception("Failed generation-bound decryption from $peerID")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign data using our static identity key
|
* Sign data using our static identity key
|
||||||
@ -189,6 +270,25 @@ class EncryptionService(private val context: Context) {
|
|||||||
fun getPeerFingerprint(peerID: String): String? {
|
fun getPeerFingerprint(peerID: String): String? {
|
||||||
return noiseService.getPeerFingerprint(peerID)
|
return noiseService.getPeerFingerprint(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the remote static key authenticated by the live Noise handshake.
|
||||||
|
* This deliberately bypasses announcement and PeerFingerprintManager
|
||||||
|
* caches; callers making downgrade decisions must bind to live channel
|
||||||
|
* authentication, not a self-certified identity payload.
|
||||||
|
*/
|
||||||
|
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
|
||||||
|
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
|
||||||
|
noiseService.getAuthenticatedSession(peerID)
|
||||||
|
|
||||||
|
fun withAuthenticatedSession(
|
||||||
|
peerID: String,
|
||||||
|
expectedSession: AuthenticatedNoiseSession,
|
||||||
|
action: () -> Boolean
|
||||||
|
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current peer ID for a fingerprint (for peer ID rotation)
|
* Get current peer ID for a fingerprint (for peer ID rotation)
|
||||||
@ -200,9 +300,9 @@ class EncryptionService(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Initiate a Noise handshake with a peer
|
* Initiate a Noise handshake with a peer
|
||||||
*/
|
*/
|
||||||
fun initiateHandshake(peerID: String): ByteArray? {
|
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
|
||||||
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
|
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
|
||||||
return noiseService.initiateHandshake(peerID)
|
return noiseService.initiateHandshake(peerID, replaceEstablished)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -212,11 +312,24 @@ class EncryptionService(private val context: Context) {
|
|||||||
Log.d(TAG, "🤝 Processing handshake message from $peerID")
|
Log.d(TAG, "🤝 Processing handshake message from $peerID")
|
||||||
return noiseService.processHandshakeMessage(data, peerID)
|
return noiseService.processHandshakeMessage(data, peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process one Noise handshake frame while preserving whether this exact call authenticated a
|
||||||
|
* new session. Unlike the response-only compatibility API, binding failures are propagated.
|
||||||
|
*/
|
||||||
|
@Throws(Exception::class)
|
||||||
|
open fun processHandshakeMessageWithResult(
|
||||||
|
data: ByteArray,
|
||||||
|
peerID: String
|
||||||
|
): NoiseHandshakeProcessingResult {
|
||||||
|
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
|
||||||
|
return noiseService.processHandshakeMessageWithResult(data, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a peer session (called when peer disconnects)
|
* Remove a peer session (called when peer disconnects)
|
||||||
*/
|
*/
|
||||||
fun removePeer(peerID: String) {
|
open fun removePeer(peerID: String) {
|
||||||
establishedSessions.remove(peerID)
|
establishedSessions.remove(peerID)
|
||||||
noiseService.removePeer(peerID)
|
noiseService.removePeer(peerID)
|
||||||
onSessionLost?.invoke(peerID)
|
onSessionLost?.invoke(peerID)
|
||||||
@ -321,4 +434,92 @@ class EncryptionService(private val context: Context) {
|
|||||||
noiseService.shutdown()
|
noiseService.shutdown()
|
||||||
Log.d(TAG, "🔌 EncryptionService shut down")
|
Log.d(TAG, "🔌 EncryptionService shut down")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Ed25519 Signature Verification
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Ed25519 signature against data using a public key
|
||||||
|
*/
|
||||||
|
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
|
||||||
|
val verifier = Ed25519Signer()
|
||||||
|
verifier.init(false, publicKey)
|
||||||
|
verifier.update(data, 0, data.size)
|
||||||
|
val isValid = verifier.verifySignature(signature)
|
||||||
|
Log.d(TAG, "✅ Ed25519 signature verification: $isValid")
|
||||||
|
isValid
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Failed to verify Ed25519 signature: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Key Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing Ed25519 key pair from preferences or create a new one
|
||||||
|
*/
|
||||||
|
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
|
||||||
|
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
|
||||||
|
migrateOldEd25519KeyIfNeeded()
|
||||||
|
try {
|
||||||
|
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
|
||||||
|
|
||||||
|
if (storedKey != null) {
|
||||||
|
// Load existing key
|
||||||
|
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT)
|
||||||
|
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
|
||||||
|
val publicKey = privateKey.generatePublicKey()
|
||||||
|
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
|
||||||
|
return AsymmetricCipherKeyPair(publicKey, privateKey)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "⚠️ Failed to load existing Ed25519 key, creating new one: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new key pair
|
||||||
|
return generateAndSaveEd25519KeyPair()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair {
|
||||||
|
val keyGen = Ed25519KeyPairGenerator()
|
||||||
|
keyGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
|
||||||
|
val keyPair = keyGen.generateKeyPair()
|
||||||
|
|
||||||
|
// Store private key in preferences
|
||||||
|
try {
|
||||||
|
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
|
||||||
|
val privateKeyBytes = privateKey.encoded
|
||||||
|
val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT)
|
||||||
|
|
||||||
|
prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) }
|
||||||
|
Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyPair
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrateOldEd25519KeyIfNeeded() {
|
||||||
|
try {
|
||||||
|
// old existing plain text preference
|
||||||
|
val oldPrefs = context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val oldKey = oldPrefs.getString(ED25519_PRIVATE_KEY_PREF, null)
|
||||||
|
|
||||||
|
if (oldKey != null && !prefs.contains(ED25519_PRIVATE_KEY_PREF)) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(ED25519_PRIVATE_KEY_PREF, oldKey)
|
||||||
|
}
|
||||||
|
oldPrefs.edit {
|
||||||
|
remove(ED25519_PRIVATE_KEY_PREF)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "🔁 Migrated Ed25519 key to EncryptedSharedPreferences")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "⚠️ Failed to migrate Ed25519 key; generating new identity: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.bitchat.android.favorites
|
||||||
|
|
||||||
|
import com.bitchat.android.services.ContactIdentityResolver
|
||||||
|
|
||||||
|
data class FavoriteControlMessage(
|
||||||
|
val isFavorite: Boolean,
|
||||||
|
val npub: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val FAVORITED = "[FAVORITED]"
|
||||||
|
private const val UNFAVORITED = "[UNFAVORITED]"
|
||||||
|
|
||||||
|
fun parse(content: String): FavoriteControlMessage? {
|
||||||
|
val trimmed = content.trim()
|
||||||
|
val isFavorite = when {
|
||||||
|
trimmed.startsWith(FAVORITED) -> true
|
||||||
|
trimmed.startsWith(UNFAVORITED) -> false
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val encodedKey = trimmed.substringAfter(":", "").trim()
|
||||||
|
val npub = encodedKey
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
|
||||||
|
?.let { ContactIdentityResolver.npubFromHex(it) }
|
||||||
|
return FavoriteControlMessage(isFavorite = isFavorite, npub = npub)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encode(isFavorite: Boolean, npub: String?): String {
|
||||||
|
val prefix = if (isFavorite) FAVORITED else UNFAVORITED
|
||||||
|
return "$prefix:${npub.orEmpty()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,409 @@
|
|||||||
|
package com.bitchat.android.favorites
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.services.AppStateStore
|
||||||
|
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||||
|
import com.bitchat.android.services.ContactIdentityResolver
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridging Noise and Nostr favorites
|
||||||
|
*/
|
||||||
|
data class FavoriteRelationship(
|
||||||
|
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||||
|
val peerNostrPublicKey: String?, // npub bech32 string
|
||||||
|
val peerNickname: String,
|
||||||
|
val isFavorite: Boolean, // We favorited them
|
||||||
|
val theyFavoritedUs: Boolean, // They favorited us
|
||||||
|
val favoritedAt: Date,
|
||||||
|
val lastUpdated: Date
|
||||||
|
) {
|
||||||
|
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as FavoriteRelationship
|
||||||
|
|
||||||
|
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
|
||||||
|
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
|
||||||
|
if (peerNickname != other.peerNickname) return false
|
||||||
|
if (isFavorite != other.isFavorite) return false
|
||||||
|
if (theyFavoritedUs != other.theyFavoritedUs) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = peerNoisePublicKey.contentHashCode()
|
||||||
|
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + peerNickname.hashCode()
|
||||||
|
result = 31 * result + isFavorite.hashCode()
|
||||||
|
result = 31 * result + theyFavoritedUs.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun FavoriteRelationship?.withPeerFavoritedUs(
|
||||||
|
noisePublicKey: ByteArray,
|
||||||
|
theyFavoritedUs: Boolean,
|
||||||
|
now: Date = Date()
|
||||||
|
): FavoriteRelationship {
|
||||||
|
return this?.copy(
|
||||||
|
theyFavoritedUs = theyFavoritedUs,
|
||||||
|
lastUpdated = now
|
||||||
|
) ?: FavoriteRelationship(
|
||||||
|
peerNoisePublicKey = noisePublicKey,
|
||||||
|
peerNostrPublicKey = null,
|
||||||
|
peerNickname = "Unknown",
|
||||||
|
isFavorite = false,
|
||||||
|
theyFavoritedUs = theyFavoritedUs,
|
||||||
|
favoritedAt = now,
|
||||||
|
lastUpdated = now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FavoritesChangeListener {
|
||||||
|
fun onFavoriteChanged(noiseKeyHex: String)
|
||||||
|
fun onAllCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages favorites with Noise↔Nostr mapping
|
||||||
|
* Singleton pattern matching iOS implementation.
|
||||||
|
*/
|
||||||
|
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FavoritesPersistenceService"
|
||||||
|
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
|
||||||
|
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: FavoritesPersistenceService? = null
|
||||||
|
|
||||||
|
val shared: FavoritesPersistenceService
|
||||||
|
get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized")
|
||||||
|
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (INSTANCE == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (INSTANCE == null) {
|
||||||
|
INSTANCE = FavoritesPersistenceService(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateManager = SecureIdentityStateManager(context)
|
||||||
|
private val gson = Gson()
|
||||||
|
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
|
||||||
|
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
|
||||||
|
private val listeners = mutableListOf<FavoritesChangeListener>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadFavorites()
|
||||||
|
loadPeerIdIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get favorite status for Noise public key */
|
||||||
|
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
||||||
|
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
|
||||||
|
return favorites[keyHex]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
|
||||||
|
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
||||||
|
val pid = peerID.trim().lowercase()
|
||||||
|
|
||||||
|
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
|
||||||
|
return favorites[pid]
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
|
||||||
|
return favorites.values.firstOrNull { relationship ->
|
||||||
|
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
|
||||||
|
.equals(fingerprint, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContactIdentityResolver.isMeshPeerId(pid)) {
|
||||||
|
peerIdIndex[pid]?.let { indexedNpub ->
|
||||||
|
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
|
||||||
|
}
|
||||||
|
return favorites.values.firstOrNull { relationship ->
|
||||||
|
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update Nostr public key for a peer (indexed by Noise key) */
|
||||||
|
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
||||||
|
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
|
||||||
|
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
|
||||||
|
?.let { ContactIdentityResolver.npubFromHex(it) }
|
||||||
|
?: nostrPubkey
|
||||||
|
val existing = favorites[keyHex]
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
val updated = existing.copy(
|
||||||
|
peerNostrPublicKey = normalizedNpub,
|
||||||
|
lastUpdated = Date()
|
||||||
|
)
|
||||||
|
favorites[keyHex] = updated
|
||||||
|
} else {
|
||||||
|
val relationship = FavoriteRelationship(
|
||||||
|
peerNoisePublicKey = noisePublicKey,
|
||||||
|
peerNostrPublicKey = normalizedNpub,
|
||||||
|
peerNickname = "Unknown",
|
||||||
|
isFavorite = false,
|
||||||
|
theyFavoritedUs = false,
|
||||||
|
favoritedAt = Date(),
|
||||||
|
lastUpdated = Date()
|
||||||
|
)
|
||||||
|
favorites[keyHex] = relationship
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFavorites()
|
||||||
|
notifyChanged(keyHex)
|
||||||
|
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Update Nostr pubkey for a specific mesh peerID. */
|
||||||
|
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
|
||||||
|
val pid = peerID.trim().lowercase()
|
||||||
|
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
|
||||||
|
?.let { ContactIdentityResolver.npubFromHex(it) }
|
||||||
|
?: nostrPubkey
|
||||||
|
if (ContactIdentityResolver.isMeshPeerId(pid)) {
|
||||||
|
peerIdIndex[pid] = normalizedNpub
|
||||||
|
savePeerIdIndex()
|
||||||
|
notifyChanged(pid)
|
||||||
|
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
|
||||||
|
fun findNostrPubkeyForPeerID(peerID: String): String? {
|
||||||
|
val pid = peerID.trim().lowercase()
|
||||||
|
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
|
||||||
|
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
|
||||||
|
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
|
||||||
|
|
||||||
|
peerIdIndex.entries.firstOrNull { (_, stored) ->
|
||||||
|
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
|
||||||
|
}?.let { return it.key }
|
||||||
|
|
||||||
|
favorites.values.firstOrNull { relationship ->
|
||||||
|
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex
|
||||||
|
}?.let { relationship ->
|
||||||
|
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update favorite status */
|
||||||
|
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
||||||
|
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
|
||||||
|
|
||||||
|
val existing = favorites[keyHex]
|
||||||
|
|
||||||
|
val updated = if (existing != null) {
|
||||||
|
existing.copy(
|
||||||
|
peerNickname = nickname,
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
lastUpdated = Date(),
|
||||||
|
favoritedAt = if (isFavorite && !existing.isFavorite) Date() else existing.favoritedAt
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FavoriteRelationship(
|
||||||
|
peerNoisePublicKey = noisePublicKey,
|
||||||
|
peerNostrPublicKey = null,
|
||||||
|
peerNickname = nickname,
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
theyFavoritedUs = false,
|
||||||
|
favoritedAt = Date(),
|
||||||
|
lastUpdated = Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites[keyHex] = updated
|
||||||
|
saveFavorites()
|
||||||
|
notifyChanged(keyHex)
|
||||||
|
|
||||||
|
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update peer favorited-us flag */
|
||||||
|
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||||
|
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
|
||||||
|
val existing = favorites[keyHex]
|
||||||
|
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
|
||||||
|
|
||||||
|
favorites[keyHex] = updated
|
||||||
|
saveFavorites()
|
||||||
|
notifyChanged(keyHex)
|
||||||
|
|
||||||
|
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
|
||||||
|
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
|
||||||
|
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
|
||||||
|
|
||||||
|
fun clearAllFavorites() {
|
||||||
|
favorites.clear()
|
||||||
|
saveFavorites()
|
||||||
|
peerIdIndex.clear()
|
||||||
|
savePeerIdIndex()
|
||||||
|
Log.i(TAG, "Cleared all favorites")
|
||||||
|
notifyAllCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find Noise key by Nostr pubkey */
|
||||||
|
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||||
|
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
|
||||||
|
return favorites.values.firstOrNull { rel ->
|
||||||
|
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex
|
||||||
|
}?.peerNoisePublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find Nostr pubkey by Noise key */
|
||||||
|
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
||||||
|
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
|
||||||
|
return favorites[keyHex]?.peerNostrPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private fun loadFavorites() {
|
||||||
|
try {
|
||||||
|
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
||||||
|
if (favoritesJson != null) {
|
||||||
|
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
||||||
|
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
|
||||||
|
|
||||||
|
favorites.clear()
|
||||||
|
data.forEach { (key, relationshipData) ->
|
||||||
|
favorites[key] = relationshipData.toFavoriteRelationship()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load favorites: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFavorites() {
|
||||||
|
try {
|
||||||
|
val data = favorites.mapValues { (_, relationship) ->
|
||||||
|
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
||||||
|
}
|
||||||
|
val favoritesJson = gson.toJson(data)
|
||||||
|
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
||||||
|
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = stateManager.getSecureValue(PEERID_INDEX_KEY)
|
||||||
|
if (json != null) {
|
||||||
|
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val data: Map<String, String> = gson.fromJson(json, type)
|
||||||
|
peerIdIndex.clear()
|
||||||
|
data.forEach { (peerID, npub) ->
|
||||||
|
val normalizedPeerID = peerID.lowercase()
|
||||||
|
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
|
||||||
|
peerIdIndex[normalizedPeerID] = npub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(peerIdIndex)
|
||||||
|
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
|
||||||
|
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listeners
|
||||||
|
fun addListener(listener: FavoritesChangeListener) {
|
||||||
|
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
|
||||||
|
}
|
||||||
|
fun removeListener(listener: FavoritesChangeListener) {
|
||||||
|
synchronized(listeners) { listeners.remove(listener) }
|
||||||
|
}
|
||||||
|
private fun notifyChanged(noiseKeyHex: String) {
|
||||||
|
runCatching { AppStateStore.canonicalizePrivateChats() }
|
||||||
|
val snapshot = synchronized(listeners) { listeners.toList() }
|
||||||
|
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
|
||||||
|
}
|
||||||
|
private fun notifyAllCleared() {
|
||||||
|
val snapshot = synchronized(listeners) { listeners.toList() }
|
||||||
|
snapshot.forEach { runCatching { it.onAllCleared() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializable data for JSON storage */
|
||||||
|
private data class FavoriteRelationshipData(
|
||||||
|
val peerNoisePublicKeyHex: String,
|
||||||
|
val peerNostrPublicKey: String?,
|
||||||
|
val peerNickname: String,
|
||||||
|
val isFavorite: Boolean,
|
||||||
|
val theyFavoritedUs: Boolean,
|
||||||
|
val favoritedAt: Long,
|
||||||
|
val lastUpdated: Long
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
|
||||||
|
return FavoriteRelationshipData(
|
||||||
|
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
|
||||||
|
peerNostrPublicKey = relationship.peerNostrPublicKey,
|
||||||
|
peerNickname = relationship.peerNickname,
|
||||||
|
isFavorite = relationship.isFavorite,
|
||||||
|
theyFavoritedUs = relationship.theyFavoritedUs,
|
||||||
|
favoritedAt = relationship.favoritedAt.time,
|
||||||
|
lastUpdated = relationship.lastUpdated.time
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toFavoriteRelationship(): FavoriteRelationship {
|
||||||
|
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
|
||||||
|
return FavoriteRelationship(
|
||||||
|
peerNoisePublicKey = noiseKeyBytes,
|
||||||
|
peerNostrPublicKey = peerNostrPublicKey,
|
||||||
|
peerNickname = peerNickname,
|
||||||
|
isFavorite = isFavorite,
|
||||||
|
theyFavoritedUs = theyFavoritedUs,
|
||||||
|
favoritedAt = Date(favoritedAt),
|
||||||
|
lastUpdated = Date(lastUpdated)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
404
app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
package com.bitchat.android.features.file
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
object FileUtils {
|
||||||
|
|
||||||
|
private const val TAG = "FileUtils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a file from URI to app's file directory with unique filename
|
||||||
|
*/
|
||||||
|
fun saveFileFromUri(
|
||||||
|
context: Context,
|
||||||
|
uri: Uri,
|
||||||
|
originalName: String? = null
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
if (inputStream == null) {
|
||||||
|
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
Log.d(TAG, "📂 Opened input stream successfully")
|
||||||
|
|
||||||
|
// Determine file extension
|
||||||
|
val extension = originalName?.substringAfterLast(".") ?: "bin"
|
||||||
|
val fileName = "file_${System.currentTimeMillis()}.$extension"
|
||||||
|
|
||||||
|
// Create incoming dir if needed
|
||||||
|
val incomingDir = File(context.filesDir, "files/incoming").apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val file = File(incomingDir, fileName)
|
||||||
|
|
||||||
|
inputStream.use { input ->
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Saved file to: ${file.absolutePath}")
|
||||||
|
file.absolutePath
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save file from URI", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy file to app's outgoing directory for sending
|
||||||
|
*/
|
||||||
|
fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? {
|
||||||
|
Log.d(TAG, "🔄 Starting file copy from URI: $uri")
|
||||||
|
return try {
|
||||||
|
val inputStream = context.contentResolver.openInputStream(uri)
|
||||||
|
if (inputStream == null) {
|
||||||
|
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
Log.d(TAG, "📂 Opened input stream successfully")
|
||||||
|
|
||||||
|
// Determine original filename and extension if available
|
||||||
|
val displayName = originalName ?: run {
|
||||||
|
try {
|
||||||
|
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() }
|
||||||
|
?: run {
|
||||||
|
// Try mime type to extension
|
||||||
|
val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null }
|
||||||
|
android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
|
||||||
|
}
|
||||||
|
// Preserve original filename (without artificial prefixes), ensure uniqueness
|
||||||
|
val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||||
|
?: "file"
|
||||||
|
var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName
|
||||||
|
|
||||||
|
// Create outgoing dir if needed
|
||||||
|
val outgoingDir = File(context.filesDir, "files/outgoing").apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = File(outgoingDir, fileName)
|
||||||
|
if (target.exists()) {
|
||||||
|
var idx = 1
|
||||||
|
val pureBase = baseName
|
||||||
|
val dotExt = if (extension.isNotBlank()) ".${extension}" else ""
|
||||||
|
while (target.exists() && idx < 1000) {
|
||||||
|
fileName = "$pureBase ($idx)$dotExt"
|
||||||
|
target = File(outgoingDir, fileName)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputStream.use { input ->
|
||||||
|
FileOutputStream(target).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}")
|
||||||
|
Log.d(TAG, "📊 Final file size: ${target.length()} bytes")
|
||||||
|
target.absolutePath
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e)
|
||||||
|
Log.e(TAG, "❌ Source URI: $uri")
|
||||||
|
Log.e(TAG, "❌ Original name: $originalName")
|
||||||
|
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type for a file based on extension
|
||||||
|
*/
|
||||||
|
fun getMimeTypeFromExtension(fileName: String): String {
|
||||||
|
return when (fileName.substringAfterLast(".", "").lowercase()) {
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
"doc" -> "application/msword"
|
||||||
|
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
"xls" -> "application/vnd.ms-excel"
|
||||||
|
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
"ppt" -> "application/vnd.ms-powerpoint"
|
||||||
|
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
"txt" -> "text/plain"
|
||||||
|
"json" -> "application/json"
|
||||||
|
"xml" -> "application/xml"
|
||||||
|
"csv" -> "text/csv"
|
||||||
|
"html", "htm" -> "text/html"
|
||||||
|
"jpg", "jpeg" -> "image/jpeg"
|
||||||
|
"png" -> "image/png"
|
||||||
|
"gif" -> "image/gif"
|
||||||
|
"bmp" -> "image/bmp"
|
||||||
|
"webp" -> "image/webp"
|
||||||
|
"svg" -> "image/svg+xml"
|
||||||
|
"mp3" -> "audio/mpeg"
|
||||||
|
"wav" -> "audio/wav"
|
||||||
|
"m4a" -> "audio/mp4"
|
||||||
|
"mp4" -> "video/mp4"
|
||||||
|
"avi" -> "video/x-msvideo"
|
||||||
|
"mov" -> "video/quicktime"
|
||||||
|
"zip" -> "application/zip"
|
||||||
|
"rar" -> "application/vnd.rar"
|
||||||
|
"7z" -> "application/x-7z-compressed"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display
|
||||||
|
*/
|
||||||
|
fun formatFileSize(bytes: Long): String {
|
||||||
|
val units = arrayOf("B", "KB", "MB", "GB")
|
||||||
|
var size = bytes.toDouble()
|
||||||
|
var unitIndex = 0
|
||||||
|
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||||
|
size /= 1024.0
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
return "%.1f %s".format(size, units[unitIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is viewable in system viewer
|
||||||
|
*/
|
||||||
|
fun isFileViewable(fileName: String): Boolean {
|
||||||
|
val extension = fileName.substringAfterLast(".", "").lowercase()
|
||||||
|
return extension in listOf(
|
||||||
|
"pdf", "txt", "json", "xml", "html", "htm", "csv",
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an incoming file packet to app storage and return absolute path.
|
||||||
|
* Mirrors existing behavior used in MessageHandler (preserves names and folders).
|
||||||
|
*/
|
||||||
|
fun saveIncomingFile(
|
||||||
|
context: Context,
|
||||||
|
file: com.bitchat.android.model.BitchatFilePacket
|
||||||
|
): String {
|
||||||
|
val lowerMime = file.mimeType.lowercase()
|
||||||
|
val isImage = lowerMime.startsWith("image/")
|
||||||
|
// FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592)
|
||||||
|
// Files in cacheDir are eligible for automatic system cleanup when space is low
|
||||||
|
val baseDir = context.cacheDir
|
||||||
|
val subdir = if (isImage) "images/incoming" else "files/incoming"
|
||||||
|
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
|
||||||
|
|
||||||
|
fun extFromMime(m: String): String = when (m.lowercase()) {
|
||||||
|
"image/jpeg", "image/jpg" -> ".jpg"
|
||||||
|
"image/png" -> ".png"
|
||||||
|
"image/webp" -> ".webp"
|
||||||
|
"application/pdf" -> ".pdf"
|
||||||
|
"text/plain" -> ".txt"
|
||||||
|
else -> if (isImage) ".jpg" else ".bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer transmitted original name; ensure uniqueness to avoid overwrites
|
||||||
|
val baseName = (file.fileName.takeIf { it.isNotBlank() }
|
||||||
|
?: (if (isImage) "img" else "file"))
|
||||||
|
.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||||
|
val ext = extFromMime(lowerMime)
|
||||||
|
var safeName = if (baseName.contains('.')) baseName else baseName + ext
|
||||||
|
var idx = 1
|
||||||
|
while (java.io.File(dir, safeName).exists() && idx < 1000) {
|
||||||
|
val dot = safeName.lastIndexOf('.')
|
||||||
|
safeName = if (dot > 0) {
|
||||||
|
val b = safeName.substring(0, dot)
|
||||||
|
val e = safeName.substring(dot)
|
||||||
|
"$b ($idx)$e"
|
||||||
|
} else {
|
||||||
|
"$safeName ($idx)"
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val out = java.io.File(dir, safeName)
|
||||||
|
out.outputStream().use { it.write(file.content) }
|
||||||
|
out.absolutePath
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fallback to cache dir with uniqueness
|
||||||
|
try {
|
||||||
|
var fallback = safeName
|
||||||
|
var idx2 = 1
|
||||||
|
while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) {
|
||||||
|
val dot = fallback.lastIndexOf('.')
|
||||||
|
fallback = if (dot > 0) {
|
||||||
|
val b = fallback.substring(0, dot)
|
||||||
|
val e = fallback.substring(dot)
|
||||||
|
"$b ($idx2)$e"
|
||||||
|
} else {
|
||||||
|
"$fallback ($idx2)"
|
||||||
|
}
|
||||||
|
idx2++
|
||||||
|
}
|
||||||
|
val out = java.io.File(context.cacheDir, fallback)
|
||||||
|
out.outputStream().use { it.write(file.content) }
|
||||||
|
out.absolutePath
|
||||||
|
} catch (_: Exception) {
|
||||||
|
val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin")
|
||||||
|
tmp.writeBytes(file.content)
|
||||||
|
tmp.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify BitchatMessageType from MIME string used in file messages.
|
||||||
|
*/
|
||||||
|
fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType {
|
||||||
|
val lower = mime.lowercase()
|
||||||
|
return when {
|
||||||
|
lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image
|
||||||
|
lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio
|
||||||
|
else -> com.bitchat.android.model.BitchatMessageType.File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively delete all media files (incoming and outgoing)
|
||||||
|
* Used for Panic Mode cleanup
|
||||||
|
*/
|
||||||
|
fun clearAllMedia(context: Context) {
|
||||||
|
try {
|
||||||
|
// Clear files dir subdirectories (legacy storage and outgoing)
|
||||||
|
val filesDir = context.filesDir
|
||||||
|
val dirsToClear = listOf(
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"voicenotes"
|
||||||
|
)
|
||||||
|
|
||||||
|
dirsToClear.forEach { subDir ->
|
||||||
|
val dir = File(filesDir, subDir)
|
||||||
|
if (dir.exists()) {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache dir subdirectories (new incoming storage)
|
||||||
|
// Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these
|
||||||
|
// specific media folders are targeted even if full cache clear fails or is modified later.
|
||||||
|
val cacheDir = context.cacheDir
|
||||||
|
val cacheDirsToClear = listOf(
|
||||||
|
"files/incoming",
|
||||||
|
"images/incoming"
|
||||||
|
)
|
||||||
|
|
||||||
|
cacheDirsToClear.forEach { subDir ->
|
||||||
|
val dir = File(cacheDir, subDir)
|
||||||
|
if (dir.exists()) {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear entire cache dir as a catch-all
|
||||||
|
context.cacheDir.deleteRecursively()
|
||||||
|
Log.d(TAG, "Cleared entire cache directory")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear media files", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete app-owned media referenced only by the conversation being removed.
|
||||||
|
*
|
||||||
|
* Canonical-path checks prevent message content from turning this into an arbitrary-file
|
||||||
|
* deletion primitive. Shared paths remain intact while any retained message still references
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
fun deleteConversationMedia(
|
||||||
|
context: Context,
|
||||||
|
deletedMessages: Collection<BitchatMessage>,
|
||||||
|
retainedMessages: Collection<BitchatMessage>
|
||||||
|
) {
|
||||||
|
val mediaTypes = setOf(
|
||||||
|
BitchatMessageType.Audio,
|
||||||
|
BitchatMessageType.Image,
|
||||||
|
BitchatMessageType.File
|
||||||
|
)
|
||||||
|
val roots = listOf(context.filesDir, context.cacheDir)
|
||||||
|
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
|
||||||
|
val retainedPaths = retainedMessages
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.type in mediaTypes }
|
||||||
|
.mapNotNull { message ->
|
||||||
|
runCatching { File(message.content.trim()).canonicalPath }.getOrNull()
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
deletedMessages
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.type in mediaTypes }
|
||||||
|
.mapNotNull { message ->
|
||||||
|
runCatching { File(message.content.trim()).canonicalFile }.getOrNull()
|
||||||
|
}
|
||||||
|
.distinctBy(File::getPath)
|
||||||
|
.filter { file ->
|
||||||
|
file.path !in retainedPaths &&
|
||||||
|
roots.any { root ->
|
||||||
|
file.path == root.path ||
|
||||||
|
file.path.startsWith(root.path + File.separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.forEach { file ->
|
||||||
|
runCatching {
|
||||||
|
if (file.isFile && !file.delete()) {
|
||||||
|
Log.w(TAG, "Unable to delete conversation media")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes paths proven by the conversation database to have no remaining message reference.
|
||||||
|
* The same canonical-root boundary as explicit conversation deletion prevents arbitrary paths
|
||||||
|
* from being removed even if persisted metadata is malformed.
|
||||||
|
*/
|
||||||
|
fun deleteStoredMediaPaths(context: Context, paths: Collection<String>) {
|
||||||
|
val roots = listOf(context.filesDir, context.cacheDir)
|
||||||
|
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
|
||||||
|
paths.asSequence()
|
||||||
|
.mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() }
|
||||||
|
.distinctBy(File::getPath)
|
||||||
|
.filter { file ->
|
||||||
|
roots.any { root ->
|
||||||
|
file.path == root.path ||
|
||||||
|
file.path.startsWith(root.path + File.separator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.forEach { file ->
|
||||||
|
runCatching {
|
||||||
|
if (file.isFile && !file.delete()) {
|
||||||
|
Log.w(TAG, "Unable to delete unreferenced conversation media")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
package com.bitchat.android.features.media
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
object ImageUtils {
|
||||||
|
fun downscaleAndSaveToAppFiles(context: Context, uri: Uri, maxDim: Int = 512, quality: Int = 85): String? {
|
||||||
|
return try {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val exifRotation = resolver.openInputStream(uri)?.use { getRotationDegreesFromExif(it) } ?: 0
|
||||||
|
|
||||||
|
// Reopen for decode as the previous stream is consumed
|
||||||
|
val input = resolver.openInputStream(uri) ?: return null
|
||||||
|
val original = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
original ?: return null
|
||||||
|
|
||||||
|
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
|
||||||
|
|
||||||
|
val w = oriented.width
|
||||||
|
val h = oriented.height
|
||||||
|
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
|
||||||
|
val newW = (w / scale).toInt().coerceAtLeast(1)
|
||||||
|
val newH = (h / scale).toInt().coerceAtLeast(1)
|
||||||
|
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
|
||||||
|
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
|
||||||
|
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
|
||||||
|
FileOutputStream(outFile).use { fos ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
|
||||||
|
}
|
||||||
|
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
|
||||||
|
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
|
||||||
|
outFile.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downscalePathAndSaveToAppFiles(context: Context, path: String, maxDim: Int = 512, quality: Int = 85): String? {
|
||||||
|
return try {
|
||||||
|
val original = BitmapFactory.decodeFile(path) ?: return null
|
||||||
|
val exifRotation = getRotationDegreesFromExif(path)
|
||||||
|
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
|
||||||
|
|
||||||
|
val w = oriented.width
|
||||||
|
val h = oriented.height
|
||||||
|
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
|
||||||
|
val newW = (w / scale).toInt().coerceAtLeast(1)
|
||||||
|
val newH = (h / scale).toInt().coerceAtLeast(1)
|
||||||
|
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
|
||||||
|
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
|
||||||
|
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
|
||||||
|
FileOutputStream(outFile).use { fos ->
|
||||||
|
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
|
||||||
|
}
|
||||||
|
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
|
||||||
|
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
|
||||||
|
outFile.absolutePath
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBitmapWithExifOrientation(path: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val base = BitmapFactory.decodeFile(path) ?: return null
|
||||||
|
val rotation = getRotationDegreesFromExif(path)
|
||||||
|
if (rotation != 0) rotateBitmap(base, rotation) else base
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotateBitmap(src: Bitmap, degrees: Int): Bitmap {
|
||||||
|
return try {
|
||||||
|
val m = Matrix()
|
||||||
|
m.postRotate(degrees.toFloat())
|
||||||
|
Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also {
|
||||||
|
try { src.recycle() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getRotationDegreesFromExif(path: String): Int = try {
|
||||||
|
val exif = ExifInterface(path)
|
||||||
|
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
|
||||||
|
} catch (_: Exception) { 0 }
|
||||||
|
|
||||||
|
private fun getRotationDegreesFromExif(stream: InputStream): Int = try {
|
||||||
|
val exif = ExifInterface(stream)
|
||||||
|
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
|
||||||
|
} catch (_: Exception) { 0 }
|
||||||
|
|
||||||
|
private fun orientationToDegrees(orientation: Int): Int = when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE -> 90
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE -> 270
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
|
||||||
|
fun interface LiveVoiceTarget {
|
||||||
|
fun send(packet: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class LiveVoiceCaptureStats(
|
||||||
|
val queuedPcmFrames: Int,
|
||||||
|
val encodedFrames: Int,
|
||||||
|
val dataPackets: Int,
|
||||||
|
val droppedOversizeFrames: Int,
|
||||||
|
val outboundPackets: Int,
|
||||||
|
val deliveredPackets: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
|
||||||
|
*
|
||||||
|
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
|
||||||
|
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
|
||||||
|
*/
|
||||||
|
internal class LiveVoiceCapture(
|
||||||
|
private val outputDirectory: File,
|
||||||
|
private val target: LiveVoiceTarget,
|
||||||
|
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
|
||||||
|
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
|
||||||
|
private val syntheticPcm: Boolean = false
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LiveVoiceCapture"
|
||||||
|
private const val SAMPLE_RATE = 16_000
|
||||||
|
private const val CHANNEL_COUNT = 1
|
||||||
|
private const val BIT_RATE = 16_000
|
||||||
|
private const val SAMPLES_PER_AAC_FRAME = 1_024
|
||||||
|
private const val MIN_VALID_DURATION_MS = 600L
|
||||||
|
private const val CODEC_TIMEOUT_US = 10_000L
|
||||||
|
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
|
||||||
|
private const val SYNTHETIC_TONE_HZ = 440.0
|
||||||
|
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
|
||||||
|
private const val AAC_FRAME_DURATION_NS = 64_000_000L
|
||||||
|
private const val OUTBOUND_QUEUE_CAPACITY = 256
|
||||||
|
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
private val amplitude = AtomicInteger(0)
|
||||||
|
private val queuedPcmFrames = AtomicInteger(0)
|
||||||
|
private val encodedFrames = AtomicInteger(0)
|
||||||
|
private val outboundPackets = AtomicInteger(0)
|
||||||
|
private val deliveredPackets = AtomicInteger(0)
|
||||||
|
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
|
||||||
|
private val senderRunning = AtomicBoolean(false)
|
||||||
|
private val packetizer = VoiceBurstPacketizer(burstID)
|
||||||
|
private var streamStarted = false
|
||||||
|
private var startedAtMs = 0L
|
||||||
|
private var totalSamples = 0L
|
||||||
|
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var codec: MediaCodec? = null
|
||||||
|
private var muxer: MediaMuxer? = null
|
||||||
|
private var muxerTrack = -1
|
||||||
|
private var muxerStarted = false
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var captureThread: Thread? = null
|
||||||
|
private var senderThread: Thread? = null
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun start(): File? {
|
||||||
|
if (running.get()) return outputFile
|
||||||
|
return try {
|
||||||
|
outputDirectory.mkdirs()
|
||||||
|
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
|
||||||
|
val file = File(outputDirectory, "voice_$burstHex.m4a")
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
outputFile = file
|
||||||
|
|
||||||
|
val format = MediaFormat.createAudioFormat(
|
||||||
|
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
CHANNEL_COUNT
|
||||||
|
).apply {
|
||||||
|
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
|
||||||
|
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
|
||||||
|
}
|
||||||
|
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||||
|
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
codec = encoder
|
||||||
|
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
muxer = mediaMuxer
|
||||||
|
val record = if (syntheticPcm) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val minBuffer = AudioRecord.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
)
|
||||||
|
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
|
||||||
|
AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
|
||||||
|
).also {
|
||||||
|
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
it.release()
|
||||||
|
error("AudioRecord did not initialize")
|
||||||
|
}
|
||||||
|
it.startRecording()
|
||||||
|
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||||
|
it.release()
|
||||||
|
error("AudioRecord did not start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRecord = record
|
||||||
|
startedAtMs = System.currentTimeMillis()
|
||||||
|
senderRunning.set(true)
|
||||||
|
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
|
||||||
|
running.set(true)
|
||||||
|
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
|
||||||
|
file
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
|
||||||
|
releaseResources()
|
||||||
|
outputFile?.delete()
|
||||||
|
outputFile = null
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pollAmplitude(): Int = amplitude.get()
|
||||||
|
|
||||||
|
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
|
||||||
|
queuedPcmFrames = queuedPcmFrames.get(),
|
||||||
|
encodedFrames = encodedFrames.get(),
|
||||||
|
dataPackets = packetizer.dataPacketCount,
|
||||||
|
droppedOversizeFrames = packetizer.droppedFrameCount,
|
||||||
|
outboundPackets = outboundPackets.get(),
|
||||||
|
deliveredPackets = deliveredPackets.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun stop(canceled: Boolean): File? {
|
||||||
|
val wasRunning = running.getAndSet(false)
|
||||||
|
if (wasRunning) {
|
||||||
|
runCatching { audioRecord?.stop() }
|
||||||
|
runCatching { captureThread?.join(3_000L) }
|
||||||
|
}
|
||||||
|
captureThread = null
|
||||||
|
|
||||||
|
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
|
||||||
|
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
|
||||||
|
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
|
||||||
|
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
|
||||||
|
(outputFile?.length() ?: 0L) > 0L
|
||||||
|
|
||||||
|
packetizer.flush().forEach(::queueOutbound)
|
||||||
|
val controlKind = if (valid) {
|
||||||
|
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
|
||||||
|
} else {
|
||||||
|
VoiceBurstPacket.Kind.Canceled
|
||||||
|
}
|
||||||
|
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
|
||||||
|
?.encode()
|
||||||
|
?.let(::queueOutbound)
|
||||||
|
senderRunning.set(false)
|
||||||
|
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
|
||||||
|
if (senderThread?.isAlive == true) {
|
||||||
|
Log.w(TAG, "Live voice sender did not drain before timeout")
|
||||||
|
senderThread?.interrupt()
|
||||||
|
}
|
||||||
|
senderThread = null
|
||||||
|
|
||||||
|
val file = outputFile
|
||||||
|
outputFile = null
|
||||||
|
if (!valid) {
|
||||||
|
file?.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoop() {
|
||||||
|
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
|
||||||
|
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
|
||||||
|
try {
|
||||||
|
while (running.get()) {
|
||||||
|
val read = if (syntheticPcm) {
|
||||||
|
val waitNs = nextSyntheticFrameNs - System.nanoTime()
|
||||||
|
if (waitNs > 0L) {
|
||||||
|
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
|
||||||
|
}
|
||||||
|
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
|
||||||
|
val firstSample = totalSamples
|
||||||
|
pcm.indices.forEach { index ->
|
||||||
|
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
|
||||||
|
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
|
||||||
|
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
|
||||||
|
}
|
||||||
|
pcm.size
|
||||||
|
} else {
|
||||||
|
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
|
||||||
|
}
|
||||||
|
if (read <= 0) continue
|
||||||
|
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
|
||||||
|
queuePcm(pcm, read, endOfStream = false)
|
||||||
|
drainEncoder(endOfStream = false)
|
||||||
|
}
|
||||||
|
queuePcm(pcm, 0, endOfStream = true)
|
||||||
|
drainEncoder(endOfStream = true)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
|
||||||
|
} finally {
|
||||||
|
releaseResources()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
|
||||||
|
val encoder = codec ?: return
|
||||||
|
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
|
||||||
|
while (true) {
|
||||||
|
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||||
|
if (index >= 0) {
|
||||||
|
val input = encoder.getInputBuffer(index)
|
||||||
|
?: error("AAC encoder returned a null input buffer")
|
||||||
|
input.clear()
|
||||||
|
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
|
||||||
|
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
|
||||||
|
totalSamples += count
|
||||||
|
encoder.queueInputBuffer(
|
||||||
|
index,
|
||||||
|
0,
|
||||||
|
count * 2,
|
||||||
|
presentationUs,
|
||||||
|
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
|
||||||
|
)
|
||||||
|
if (!endOfStream) queuedPcmFrames.incrementAndGet()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Pull encoded output before retrying so transient codec backpressure cannot discard
|
||||||
|
// the already-read 64 ms microphone block.
|
||||||
|
drainEncoder(endOfStream = false)
|
||||||
|
if (System.nanoTime() >= deadlineNs) {
|
||||||
|
error("AAC encoder input remained unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drainEncoder(endOfStream: Boolean) {
|
||||||
|
val encoder = codec ?: return
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
var sawEnd = false
|
||||||
|
var idlePolls = 0
|
||||||
|
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
|
||||||
|
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
|
||||||
|
MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||||
|
idlePolls++
|
||||||
|
if (!endOfStream) return
|
||||||
|
}
|
||||||
|
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
if (muxerStarted) error("AAC output format changed twice")
|
||||||
|
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
|
||||||
|
muxer?.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
else -> if (index >= 0) {
|
||||||
|
idlePolls = 0
|
||||||
|
val output = encoder.getOutputBuffer(index)
|
||||||
|
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||||
|
output.position(info.offset)
|
||||||
|
output.limit(info.offset + info.size)
|
||||||
|
if (muxerStarted && muxerTrack >= 0) {
|
||||||
|
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
|
||||||
|
}
|
||||||
|
val accessUnit = ByteArray(info.size)
|
||||||
|
output.get(accessUnit)
|
||||||
|
emitEncodedFrame(accessUnit)
|
||||||
|
}
|
||||||
|
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
encoder.releaseOutputBuffer(index, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitEncodedFrame(frame: ByteArray) {
|
||||||
|
if (frame.isEmpty()) return
|
||||||
|
if (!streamStarted) {
|
||||||
|
streamStarted = true
|
||||||
|
VoiceBurstPacket.create(
|
||||||
|
burstID,
|
||||||
|
0,
|
||||||
|
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
|
||||||
|
)?.encode()?.let(::queueOutbound)
|
||||||
|
}
|
||||||
|
encodedFrames.incrementAndGet()
|
||||||
|
packetizer.add(frame).forEach(::queueOutbound)
|
||||||
|
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
|
||||||
|
packetizer.flush().forEach(::queueOutbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queueOutbound(packet: ByteArray) {
|
||||||
|
outboundQueue.put(packet.copyOf())
|
||||||
|
outboundPackets.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun senderLoop() {
|
||||||
|
try {
|
||||||
|
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
|
||||||
|
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
|
||||||
|
target.send(packet)
|
||||||
|
deliveredPackets.incrementAndGet()
|
||||||
|
}
|
||||||
|
} catch (error: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
|
||||||
|
} finally {
|
||||||
|
senderRunning.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseResources() {
|
||||||
|
runCatching { audioRecord?.stop() }
|
||||||
|
runCatching { audioRecord?.release() }
|
||||||
|
audioRecord = null
|
||||||
|
runCatching { codec?.stop() }
|
||||||
|
runCatching { codec?.release() }
|
||||||
|
codec = null
|
||||||
|
if (muxerStarted) runCatching { muxer?.stop() }
|
||||||
|
runCatching { muxer?.release() }
|
||||||
|
muxer = null
|
||||||
|
muxerStarted = false
|
||||||
|
muxerTrack = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,478 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.BitchatMessageType
|
||||||
|
import com.bitchat.android.services.AppStateStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.TreeMap
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH }
|
||||||
|
|
||||||
|
sealed interface LiveVoiceEvent {
|
||||||
|
val peerID: String
|
||||||
|
val burstID: String
|
||||||
|
val scope: LiveVoiceScope
|
||||||
|
|
||||||
|
data class Started(
|
||||||
|
override val peerID: String,
|
||||||
|
override val burstID: String,
|
||||||
|
override val scope: LiveVoiceScope
|
||||||
|
) : LiveVoiceEvent
|
||||||
|
|
||||||
|
data class Finished(
|
||||||
|
override val peerID: String,
|
||||||
|
override val burstID: String,
|
||||||
|
override val scope: LiveVoiceScope,
|
||||||
|
val dataPackets: Int,
|
||||||
|
val frames: Int,
|
||||||
|
val bytes: Int,
|
||||||
|
val expectedPackets: Int?,
|
||||||
|
val missingPackets: Int,
|
||||||
|
val path: String
|
||||||
|
) : LiveVoiceEvent
|
||||||
|
|
||||||
|
data class Canceled(
|
||||||
|
override val peerID: String,
|
||||||
|
override val burstID: String,
|
||||||
|
override val scope: LiveVoiceScope
|
||||||
|
) : LiveVoiceEvent
|
||||||
|
|
||||||
|
data class Absorbed(
|
||||||
|
override val peerID: String,
|
||||||
|
override val burstID: String,
|
||||||
|
override val scope: LiveVoiceScope,
|
||||||
|
val finalizedPath: String
|
||||||
|
) : LiveVoiceEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */
|
||||||
|
class LiveVoiceManager private constructor(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LiveVoiceManager"
|
||||||
|
private const val MAX_CONCURRENT_ASSEMBLIES = 8
|
||||||
|
private const val MAX_BURST_BYTES = 384 * 1_024
|
||||||
|
private const val INBOUND_BYTES_PER_SECOND = 6_000
|
||||||
|
private const val MAX_BUFFERED_PACKETS = 128
|
||||||
|
private const val GAP_SKIP_MS = 550L
|
||||||
|
private const val IDLE_TIMEOUT_MS = 3_000L
|
||||||
|
private const val FINISHED_TTL_MS = 10 * 60 * 1_000L
|
||||||
|
private const val FINISHED_CAP = 32
|
||||||
|
|
||||||
|
// The manager constructor immediately narrows this to context.applicationContext.
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
@Volatile private var instance: LiveVoiceManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) {
|
||||||
|
instance ?: LiveVoiceManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun burstIDFromVoiceFileName(fileName: String): String? {
|
||||||
|
if (!fileName.startsWith("voice_")) return null
|
||||||
|
val id = fileName.removePrefix("voice_").take(16)
|
||||||
|
return id.takeIf { value ->
|
||||||
|
value.length == 16 && value.all { it.digitToIntOrNull(16) != null }
|
||||||
|
}?.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AssemblyKey(
|
||||||
|
val peerID: String,
|
||||||
|
val scope: LiveVoiceScope,
|
||||||
|
val burstID: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private class Assembly(
|
||||||
|
val key: AssemblyKey,
|
||||||
|
val nickname: String,
|
||||||
|
val message: BitchatMessage,
|
||||||
|
var file: File,
|
||||||
|
val output: FileOutputStream,
|
||||||
|
val startedAtMs: Long,
|
||||||
|
val player: PttAudioPlayer?
|
||||||
|
) {
|
||||||
|
val buffered = TreeMap<Int, List<ByteArray>>()
|
||||||
|
var nextSequence = 1
|
||||||
|
var deliveredFrames = 0
|
||||||
|
var deliveredPackets = 0
|
||||||
|
var missingPackets = 0
|
||||||
|
var receivedBytes = 0
|
||||||
|
var endTotalPackets: Int? = null
|
||||||
|
var idleJob: Job? = null
|
||||||
|
var gapJob: Job? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FinishedBurst(
|
||||||
|
val key: AssemblyKey,
|
||||||
|
val messageID: String,
|
||||||
|
val nickname: String,
|
||||||
|
val file: File,
|
||||||
|
val timestamp: Date,
|
||||||
|
val expiresAtMs: Long,
|
||||||
|
val dataPackets: Int,
|
||||||
|
val frames: Int,
|
||||||
|
val bytes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
|
||||||
|
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
|
||||||
|
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
|
||||||
|
private val _activePublicTalker = MutableStateFlow<String?>(null)
|
||||||
|
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
|
||||||
|
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
|
||||||
|
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
@Volatile private var appForeground = false
|
||||||
|
@Volatile private var visibleScope: LiveVoiceScope? = null
|
||||||
|
@Volatile private var visiblePeerID: String? = null
|
||||||
|
private var activePlayer: PttAudioPlayer? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
liveDirectory().listFiles()
|
||||||
|
?.filter { it.name.startsWith("voice_live_") }
|
||||||
|
?.forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppForeground(foreground: Boolean) {
|
||||||
|
appForeground = foreground
|
||||||
|
if (!foreground) {
|
||||||
|
synchronized(this) {
|
||||||
|
activePlayer?.stop()
|
||||||
|
activePlayer = null
|
||||||
|
assemblies.values.forEach { assembly -> assembly.player?.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPublicMesh() {
|
||||||
|
visibleScope = LiveVoiceScope.PUBLIC_MESH
|
||||||
|
visiblePeerID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showDirectMessage(peerID: String) {
|
||||||
|
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
|
||||||
|
visiblePeerID = peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearVisibleConversation() {
|
||||||
|
visibleScope = null
|
||||||
|
visiblePeerID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
|
||||||
|
@Synchronized
|
||||||
|
fun handleFrame(
|
||||||
|
peerID: String,
|
||||||
|
nickname: String,
|
||||||
|
scope: LiveVoiceScope,
|
||||||
|
payload: ByteArray,
|
||||||
|
timestampMs: Long
|
||||||
|
): Boolean {
|
||||||
|
val packet = VoiceBurstPacket.decode(payload) ?: return false
|
||||||
|
if (!LiveVoicePreferences.isEnabled(context)) return true
|
||||||
|
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
|
||||||
|
val key = AssemblyKey(peerID, scope, burstHex)
|
||||||
|
var assembly = assemblies[key]
|
||||||
|
if (assembly == null) {
|
||||||
|
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
|
||||||
|
assembly = createAssembly(key, nickname, timestampMs) ?: return false
|
||||||
|
assemblies[key] = assembly
|
||||||
|
publishLiveState()
|
||||||
|
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
assembly.receivedBytes += payload.size
|
||||||
|
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
|
||||||
|
if (
|
||||||
|
assembly.receivedBytes > MAX_BURST_BYTES ||
|
||||||
|
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Dropping over-quota live voice burst")
|
||||||
|
finalizeAssembly(assembly)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rescheduleIdle(assembly)
|
||||||
|
|
||||||
|
when (val kind = packet.kind) {
|
||||||
|
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
|
||||||
|
cancelAssembly(assembly)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
is VoiceBurstPacket.Kind.Frames -> {
|
||||||
|
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
|
||||||
|
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
|
||||||
|
assembly.buffered[packet.sequence] = kind.frames
|
||||||
|
drainInOrder(assembly)
|
||||||
|
}
|
||||||
|
is VoiceBurstPacket.Kind.End -> {
|
||||||
|
assembly.endTotalPackets = kind.totalDataPackets
|
||||||
|
drainInOrder(assembly)
|
||||||
|
finalizeIfComplete(assembly)
|
||||||
|
}
|
||||||
|
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
|
||||||
|
@Synchronized
|
||||||
|
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
|
||||||
|
if (message.type != BitchatMessageType.Audio) return false
|
||||||
|
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
|
||||||
|
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
|
||||||
|
val peerID = message.senderPeerID ?: return false
|
||||||
|
assemblies.entries.firstOrNull {
|
||||||
|
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||||
|
}?.value?.let(::finalizeAssembly)
|
||||||
|
pruneFinished()
|
||||||
|
val entry = finishedBursts.entries.firstOrNull {
|
||||||
|
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
|
||||||
|
} ?: return false
|
||||||
|
val finished = entry.value
|
||||||
|
val replacement = message.copy(
|
||||||
|
id = finished.messageID,
|
||||||
|
timestamp = finished.timestamp,
|
||||||
|
sender = finished.nickname,
|
||||||
|
senderPeerID = peerID,
|
||||||
|
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
|
||||||
|
)
|
||||||
|
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||||
|
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
|
||||||
|
} else {
|
||||||
|
AppStateStore.upsertPublicMessage(replacement)
|
||||||
|
}
|
||||||
|
finished.file.delete()
|
||||||
|
finishedBursts.remove(entry.key)
|
||||||
|
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun reset() {
|
||||||
|
assemblies.values.toList().forEach(::cancelAssembly)
|
||||||
|
activePlayer?.stop()
|
||||||
|
activePlayer = null
|
||||||
|
finishedBursts.clear()
|
||||||
|
publishLiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
|
||||||
|
val file = File(
|
||||||
|
liveDirectory(),
|
||||||
|
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||||
|
)
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.delete()
|
||||||
|
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
|
||||||
|
val message = BitchatMessage(
|
||||||
|
id = UUID.randomUUID().toString().uppercase(),
|
||||||
|
sender = nickname,
|
||||||
|
content = file.absolutePath,
|
||||||
|
type = BitchatMessageType.Audio,
|
||||||
|
timestamp = Date(timestampMs),
|
||||||
|
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
|
||||||
|
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
|
||||||
|
senderPeerID = key.peerID
|
||||||
|
)
|
||||||
|
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||||
|
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
|
||||||
|
} else {
|
||||||
|
AppStateStore.addPublicMessage(message)
|
||||||
|
}
|
||||||
|
val player = if (canAutoplay(key)) {
|
||||||
|
activePlayer?.stop()
|
||||||
|
PttAudioPlayer().also { activePlayer = it }
|
||||||
|
} else null
|
||||||
|
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drainInOrder(assembly: Assembly) {
|
||||||
|
while (true) {
|
||||||
|
val frames = assembly.buffered.remove(assembly.nextSequence)
|
||||||
|
if (frames != null) {
|
||||||
|
frames.forEach { frame ->
|
||||||
|
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
|
||||||
|
}
|
||||||
|
runCatching { assembly.output.flush() }
|
||||||
|
assembly.deliveredFrames += frames.size
|
||||||
|
assembly.deliveredPackets++
|
||||||
|
assembly.player?.enqueue(frames)
|
||||||
|
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
|
||||||
|
assembly.gapJob?.cancel()
|
||||||
|
assembly.gapJob = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
|
||||||
|
val key = assembly.key
|
||||||
|
assembly.gapJob = scope.launch {
|
||||||
|
delay(GAP_SKIP_MS)
|
||||||
|
synchronized(this@LiveVoiceManager) {
|
||||||
|
val current = assemblies[key] ?: return@synchronized
|
||||||
|
current.buffered.firstKey()?.let { skipGap(current, it) }
|
||||||
|
current.gapJob = null
|
||||||
|
drainInOrder(current)
|
||||||
|
finalizeIfComplete(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalizeIfComplete(assembly: Assembly) {
|
||||||
|
val total = assembly.endTotalPackets ?: return
|
||||||
|
if (assembly.nextSequence > total) finalizeAssembly(assembly)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalizeAssembly(assembly: Assembly) {
|
||||||
|
if (assemblies.remove(assembly.key) == null) return
|
||||||
|
assembly.idleJob?.cancel()
|
||||||
|
assembly.gapJob?.cancel()
|
||||||
|
while (assembly.buffered.isNotEmpty()) {
|
||||||
|
skipGap(assembly, assembly.buffered.firstKey())
|
||||||
|
drainInOrder(assembly)
|
||||||
|
if (assembly.gapJob != null) {
|
||||||
|
assembly.gapJob?.cancel()
|
||||||
|
assembly.gapJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { assembly.output.close() }
|
||||||
|
assembly.player?.finishAfterDrain()
|
||||||
|
val missingPackets = assembly.endTotalPackets
|
||||||
|
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
|
||||||
|
?: assembly.missingPackets
|
||||||
|
if (assembly.deliveredFrames == 0) {
|
||||||
|
removeBubble(assembly)
|
||||||
|
assembly.file.delete()
|
||||||
|
publishLiveState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fallback = File(
|
||||||
|
assembly.file.parentFile,
|
||||||
|
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
|
||||||
|
)
|
||||||
|
fallback.delete()
|
||||||
|
if (assembly.file.renameTo(fallback)) assembly.file = fallback
|
||||||
|
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
|
||||||
|
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||||
|
AppStateStore.upsertPrivateMessage(
|
||||||
|
assembly.key.peerID,
|
||||||
|
finalizedMessage,
|
||||||
|
isVisible(assembly.key.scope, assembly.key.peerID)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AppStateStore.upsertPublicMessage(finalizedMessage)
|
||||||
|
}
|
||||||
|
pruneFinished()
|
||||||
|
finishedBursts[assembly.key] = FinishedBurst(
|
||||||
|
key = assembly.key,
|
||||||
|
messageID = assembly.message.id,
|
||||||
|
nickname = assembly.nickname,
|
||||||
|
file = assembly.file,
|
||||||
|
timestamp = assembly.message.timestamp,
|
||||||
|
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
|
||||||
|
dataPackets = assembly.deliveredPackets,
|
||||||
|
frames = assembly.deliveredFrames,
|
||||||
|
bytes = assembly.receivedBytes
|
||||||
|
)
|
||||||
|
_events.tryEmit(
|
||||||
|
LiveVoiceEvent.Finished(
|
||||||
|
assembly.key.peerID,
|
||||||
|
assembly.key.burstID,
|
||||||
|
assembly.key.scope,
|
||||||
|
assembly.deliveredPackets,
|
||||||
|
assembly.deliveredFrames,
|
||||||
|
assembly.receivedBytes,
|
||||||
|
assembly.endTotalPackets,
|
||||||
|
missingPackets,
|
||||||
|
assembly.file.absolutePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
publishLiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelAssembly(assembly: Assembly) {
|
||||||
|
if (assemblies.remove(assembly.key) == null) return
|
||||||
|
assembly.idleJob?.cancel()
|
||||||
|
assembly.gapJob?.cancel()
|
||||||
|
assembly.player?.stop()
|
||||||
|
runCatching { assembly.output.close() }
|
||||||
|
removeBubble(assembly)
|
||||||
|
assembly.file.delete()
|
||||||
|
_events.tryEmit(
|
||||||
|
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
|
||||||
|
)
|
||||||
|
publishLiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
|
||||||
|
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
|
||||||
|
if (distance in 1..0x7FFF) assembly.missingPackets += distance
|
||||||
|
assembly.nextSequence = nextAvailableSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeBubble(assembly: Assembly) {
|
||||||
|
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
|
||||||
|
AppStateStore.removePrivateMessage(assembly.message.id)
|
||||||
|
} else {
|
||||||
|
AppStateStore.removePublicMessage(assembly.message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rescheduleIdle(assembly: Assembly) {
|
||||||
|
assembly.idleJob?.cancel()
|
||||||
|
val key = assembly.key
|
||||||
|
assembly.idleJob = scope.launch {
|
||||||
|
delay(IDLE_TIMEOUT_MS)
|
||||||
|
synchronized(this@LiveVoiceManager) {
|
||||||
|
assemblies[key]?.let(::finalizeAssembly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishLiveState() {
|
||||||
|
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
|
||||||
|
_activePublicTalker.value = assemblies.values
|
||||||
|
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
|
||||||
|
?.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneFinished() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
|
||||||
|
while (finishedBursts.size >= FINISHED_CAP) {
|
||||||
|
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
|
||||||
|
finishedBursts.remove(oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canAutoplay(key: AssemblyKey): Boolean =
|
||||||
|
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
|
||||||
|
|
||||||
|
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
|
||||||
|
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
|
||||||
|
|
||||||
|
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/** One preference gates live PTT sending and playback; finalized voice notes remain available. */
|
||||||
|
object LiveVoicePreferences {
|
||||||
|
private const val PREFERENCES = "bitchat_settings"
|
||||||
|
private const val ENABLED = "ptt.liveVoiceEnabled"
|
||||||
|
|
||||||
|
fun isEnabled(context: Context): Boolean =
|
||||||
|
context.applicationContext
|
||||||
|
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(ENABLED, true)
|
||||||
|
|
||||||
|
fun setEnabled(context: Context, enabled: Boolean) {
|
||||||
|
context.applicationContext
|
||||||
|
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean(ENABLED, enabled)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioTrack
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/** Small jitter-buffered AAC player for foreground live bursts. */
|
||||||
|
internal class PttAudioPlayer {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PttAudioPlayer"
|
||||||
|
private const val SAMPLE_RATE = 16_000
|
||||||
|
private const val FRAME_DURATION_US = 64_000L
|
||||||
|
private const val JITTER_FRAMES = 6
|
||||||
|
private const val JITTER_DEADLINE_MS = 500L
|
||||||
|
private const val CODEC_TIMEOUT_US = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val frames = LinkedBlockingQueue<ByteArray>(128)
|
||||||
|
private val stopped = AtomicBoolean(false)
|
||||||
|
private val finishing = AtomicBoolean(false)
|
||||||
|
private val startedAt = System.currentTimeMillis()
|
||||||
|
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
|
||||||
|
|
||||||
|
fun enqueue(accessUnits: List<ByteArray>) {
|
||||||
|
if (stopped.get()) return
|
||||||
|
accessUnits.forEach { frames.offer(it.copyOf()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishAfterDrain() {
|
||||||
|
finishing.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
stopped.set(true)
|
||||||
|
worker.interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playbackLoop() {
|
||||||
|
var decoder: MediaCodec? = null
|
||||||
|
var track: AudioTrack? = null
|
||||||
|
try {
|
||||||
|
while (
|
||||||
|
!stopped.get() && frames.size < JITTER_FRAMES &&
|
||||||
|
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
|
||||||
|
!finishing.get()
|
||||||
|
) {
|
||||||
|
Thread.sleep(10L)
|
||||||
|
}
|
||||||
|
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
|
||||||
|
|
||||||
|
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
|
||||||
|
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
|
||||||
|
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
|
||||||
|
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
|
||||||
|
}
|
||||||
|
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||||
|
configure(format, null, null, 0)
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
val minBuffer = AudioTrack.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_OUT_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
).coerceAtLeast(4_096)
|
||||||
|
track = AudioTrack.Builder()
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setAudioFormat(
|
||||||
|
AudioFormat.Builder()
|
||||||
|
.setSampleRate(SAMPLE_RATE)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
|
.setBufferSizeInBytes(minBuffer)
|
||||||
|
.build()
|
||||||
|
track.play()
|
||||||
|
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
var presentationUs = 0L
|
||||||
|
var inputEnded = false
|
||||||
|
var outputEnded = false
|
||||||
|
while (!stopped.get() && !outputEnded) {
|
||||||
|
if (!inputEnded) {
|
||||||
|
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
|
||||||
|
if (frame != null) {
|
||||||
|
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||||
|
if (index >= 0) {
|
||||||
|
decoder.getInputBuffer(index)?.apply {
|
||||||
|
clear()
|
||||||
|
put(frame)
|
||||||
|
}
|
||||||
|
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
|
||||||
|
presentationUs += FRAME_DURATION_US
|
||||||
|
} else {
|
||||||
|
frames.offer(frame)
|
||||||
|
}
|
||||||
|
} else if (finishing.get()) {
|
||||||
|
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
|
||||||
|
if (index >= 0) {
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
index,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
presentationUs,
|
||||||
|
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
inputEnded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
|
||||||
|
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
|
||||||
|
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
|
||||||
|
else -> if (index >= 0) {
|
||||||
|
decoder.getOutputBuffer(index)?.let { output ->
|
||||||
|
if (info.size > 0) {
|
||||||
|
output.position(info.offset)
|
||||||
|
output.limit(info.offset + info.size)
|
||||||
|
val pcm = ByteArray(info.size)
|
||||||
|
output.get(pcm)
|
||||||
|
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
decoder.releaseOutputBuffer(index, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "Live playback stopped: ${error.message}")
|
||||||
|
} finally {
|
||||||
|
stopped.set(true)
|
||||||
|
runCatching { track?.stop() }
|
||||||
|
runCatching { track?.release() }
|
||||||
|
runCatching { decoder?.stop() }
|
||||||
|
runCatching { decoder?.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */
|
||||||
|
enum class VoiceBurstCodec(val value: UByte) {
|
||||||
|
AAC_LC_16K_MONO(0x01u);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One live push-to-talk packet.
|
||||||
|
*
|
||||||
|
* Wire format (shared with iOS):
|
||||||
|
* `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`.
|
||||||
|
*/
|
||||||
|
class VoiceBurstPacket private constructor(
|
||||||
|
val burstID: ByteArray,
|
||||||
|
val sequence: Int,
|
||||||
|
val kind: Kind
|
||||||
|
) {
|
||||||
|
sealed interface Kind {
|
||||||
|
data class Start(val codec: VoiceBurstCodec) : Kind
|
||||||
|
class Frames(val frames: List<ByteArray>) : Kind {
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is Frames && frames.size == other.frames.size &&
|
||||||
|
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
|
||||||
|
}
|
||||||
|
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
|
||||||
|
data object Canceled : Kind
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encode(): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
|
||||||
|
output.write(burstID)
|
||||||
|
output.write((sequence ushr 8) and 0xFF)
|
||||||
|
output.write(sequence and 0xFF)
|
||||||
|
when (val packetKind = kind) {
|
||||||
|
is Kind.Start -> {
|
||||||
|
output.write(FLAG_START)
|
||||||
|
output.write(packetKind.codec.value.toInt())
|
||||||
|
}
|
||||||
|
is Kind.Frames -> {
|
||||||
|
output.write(0)
|
||||||
|
packetKind.frames.forEach { frame ->
|
||||||
|
output.write((frame.size ushr 8) and 0xFF)
|
||||||
|
output.write(frame.size and 0xFF)
|
||||||
|
output.write(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Kind.End -> {
|
||||||
|
output.write(FLAG_END)
|
||||||
|
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
|
||||||
|
output.write(packetKind.totalDataPackets and 0xFF)
|
||||||
|
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
|
||||||
|
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
|
||||||
|
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
|
||||||
|
output.write((packetKind.durationMs and 0xFF).toInt())
|
||||||
|
}
|
||||||
|
Kind.Canceled -> output.write(FLAG_CANCELED)
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BURST_ID_SIZE = 8
|
||||||
|
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
|
||||||
|
const val MAX_FRAMES_PER_PACKET = 8
|
||||||
|
const val MAX_CONTENT_BYTES = 210
|
||||||
|
private const val FLAG_START = 0x01
|
||||||
|
private const val FLAG_END = 0x02
|
||||||
|
private const val FLAG_CANCELED = 0x04
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
|
||||||
|
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
|
||||||
|
when (kind) {
|
||||||
|
is Kind.Frames -> if (
|
||||||
|
kind.frames.isEmpty() ||
|
||||||
|
kind.frames.size > MAX_FRAMES_PER_PACKET ||
|
||||||
|
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
|
||||||
|
) return null
|
||||||
|
is Kind.End -> if (
|
||||||
|
kind.totalDataPackets !in 0..0xFFFF ||
|
||||||
|
kind.durationMs !in 0..0xFFFF_FFFFL
|
||||||
|
) return null
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(data: ByteArray): VoiceBurstPacket? {
|
||||||
|
if (data.size < HEADER_SIZE) return null
|
||||||
|
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
|
||||||
|
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
|
||||||
|
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
|
||||||
|
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
|
||||||
|
val offset = HEADER_SIZE
|
||||||
|
val kind: Kind = when (flags) {
|
||||||
|
FLAG_START -> {
|
||||||
|
if (offset >= data.size) return null
|
||||||
|
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
|
||||||
|
Kind.Start(codec)
|
||||||
|
}
|
||||||
|
FLAG_END -> {
|
||||||
|
if (data.size - offset < 6) return null
|
||||||
|
val total = ((data[offset].toInt() and 0xFF) shl 8) or
|
||||||
|
(data[offset + 1].toInt() and 0xFF)
|
||||||
|
var duration = 0L
|
||||||
|
repeat(4) { index ->
|
||||||
|
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
|
||||||
|
}
|
||||||
|
Kind.End(total, duration)
|
||||||
|
}
|
||||||
|
FLAG_CANCELED -> Kind.Canceled
|
||||||
|
0 -> {
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
var cursor = offset
|
||||||
|
while (cursor < data.size) {
|
||||||
|
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
|
||||||
|
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
|
||||||
|
(data[cursor + 1].toInt() and 0xFF)
|
||||||
|
cursor += 2
|
||||||
|
if (length <= 0 || data.size - cursor < length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
frames += data.copyOfRange(cursor, cursor + length)
|
||||||
|
cursor += length
|
||||||
|
}
|
||||||
|
if (frames.isEmpty()) return null
|
||||||
|
Kind.Frames(frames)
|
||||||
|
}
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return create(burstID, sequence, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
|
||||||
|
|
||||||
|
fun burstIDHex(burstID: ByteArray): String =
|
||||||
|
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
|
||||||
|
class VoiceBurstPacketizer(
|
||||||
|
val burstID: ByteArray,
|
||||||
|
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
|
||||||
|
) {
|
||||||
|
private val pendingFrames = mutableListOf<ByteArray>()
|
||||||
|
private var pendingSize = 0
|
||||||
|
|
||||||
|
var nextSequence: Int = 1
|
||||||
|
private set
|
||||||
|
var dataPacketCount: Int = 0
|
||||||
|
private set
|
||||||
|
var droppedFrameCount: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun add(frame: ByteArray): List<ByteArray> {
|
||||||
|
val frameCost = 2 + frame.size
|
||||||
|
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
|
||||||
|
droppedFrameCount++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val output = mutableListOf<ByteArray>()
|
||||||
|
if (
|
||||||
|
pendingFrames.isNotEmpty() &&
|
||||||
|
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
|
||||||
|
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
|
||||||
|
) {
|
||||||
|
output += flush()
|
||||||
|
}
|
||||||
|
pendingFrames += frame.copyOf()
|
||||||
|
pendingSize += frameCost
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush(): List<ByteArray> {
|
||||||
|
if (pendingFrames.isEmpty()) return emptyList()
|
||||||
|
val packet = VoiceBurstPacket.create(
|
||||||
|
burstID,
|
||||||
|
nextSequence,
|
||||||
|
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
|
||||||
|
) ?: run {
|
||||||
|
pendingFrames.clear()
|
||||||
|
pendingSize = 0
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
pendingFrames.clear()
|
||||||
|
pendingSize = 0
|
||||||
|
nextSequence = (nextSequence + 1) and 0xFFFF
|
||||||
|
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
|
||||||
|
return listOf(packet.encode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
|
||||||
|
object AdtsFramer {
|
||||||
|
fun frame(payload: ByteArray): ByteArray {
|
||||||
|
val frameLength = payload.size + 7
|
||||||
|
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
|
||||||
|
return ByteArray(frameLength).also { output ->
|
||||||
|
output[0] = 0xFF.toByte()
|
||||||
|
output[1] = 0xF1.toByte()
|
||||||
|
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
|
||||||
|
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
|
||||||
|
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
|
||||||
|
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
|
||||||
|
output[6] = 0xFC.toByte()
|
||||||
|
payload.copyInto(output, destinationOffset = 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
|
||||||
|
* The resulting file has MIME audio/mp4.
|
||||||
|
*/
|
||||||
|
class VoiceRecorder(
|
||||||
|
private val context: Context,
|
||||||
|
private val liveTarget: LiveVoiceTarget? = null
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "VoiceRecorder" }
|
||||||
|
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var liveCapture: LiveVoiceCapture? = null
|
||||||
|
private val _amplitude = MutableStateFlow(0)
|
||||||
|
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
|
||||||
|
|
||||||
|
private var outFile: File? = null
|
||||||
|
|
||||||
|
val isLive: Boolean
|
||||||
|
get() = liveCapture != null
|
||||||
|
|
||||||
|
fun start(): File? {
|
||||||
|
stop() // ensure previous session closed
|
||||||
|
if (liveTarget != null) {
|
||||||
|
val directory = File(context.filesDir, "voicenotes/outgoing")
|
||||||
|
val capture = LiveVoiceCapture(directory, liveTarget)
|
||||||
|
val liveFile = capture.start()
|
||||||
|
if (liveFile != null) {
|
||||||
|
liveCapture = capture
|
||||||
|
outFile = liveFile
|
||||||
|
return liveFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return startClassic()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startClassic(): File? {
|
||||||
|
return try {
|
||||||
|
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
|
||||||
|
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
|
||||||
|
val file = File(dir, name)
|
||||||
|
val rec = MediaRecorder()
|
||||||
|
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
rec.setAudioChannels(1)
|
||||||
|
// Target: 16 kHz AAC @ 20 kbps ≈ 2.5 KB/sec
|
||||||
|
// Lower sample rate and bitrate for compact, speech-optimized recordings
|
||||||
|
rec.setAudioSamplingRate(16000)
|
||||||
|
rec.setAudioEncodingBitRate(20_000)
|
||||||
|
rec.setOutputFile(file.absolutePath)
|
||||||
|
rec.prepare()
|
||||||
|
rec.start()
|
||||||
|
recorder = rec
|
||||||
|
outFile = file
|
||||||
|
file
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start recording: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pollAmplitude(): Int {
|
||||||
|
return try {
|
||||||
|
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
|
||||||
|
_amplitude.value = amp
|
||||||
|
amp
|
||||||
|
} catch (_: Exception) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(canceled: Boolean = false): File? {
|
||||||
|
liveCapture?.let { capture ->
|
||||||
|
val file = capture.stop(canceled)
|
||||||
|
liveCapture = null
|
||||||
|
outFile = null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recorder?.apply {
|
||||||
|
try { stop() } catch (_: Exception) {}
|
||||||
|
try { reset() } catch (_: Exception) {}
|
||||||
|
try { release() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
val f = outFile
|
||||||
|
recorder = null
|
||||||
|
outFile = null
|
||||||
|
if (canceled) {
|
||||||
|
f?.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
val norm = min(1f, amplitude / 20_000f)
|
||||||
|
val heightFrac by animateFloatAsState(
|
||||||
|
targetValue = 0.1f + 0.9f * norm,
|
||||||
|
animationSpec = tween(120, easing = LinearEasing), label = "amp"
|
||||||
|
)
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val barCount = 24
|
||||||
|
val gap = 6f
|
||||||
|
val bw = (w - gap * (barCount - 1)) / barCount
|
||||||
|
for (i in 0 until barCount) {
|
||||||
|
val phase = (i.toFloat() / barCount)
|
||||||
|
val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h
|
||||||
|
val x = i * (bw + gap)
|
||||||
|
val y = (h - barH) / 2f
|
||||||
|
drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/src/main/java/com/bitchat/android/features/voice/Waveform.kt
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
package com.bitchat.android.features.voice
|
||||||
|
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ln
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
object VoiceWaveformCache {
|
||||||
|
private val map = ConcurrentHashMap<String, FloatArray>()
|
||||||
|
fun put(path: String, samples: FloatArray) { map[path] = samples }
|
||||||
|
fun get(path: String): FloatArray? = map[path]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeAmplitudeSample(amp: Int): Float {
|
||||||
|
val a = max(0, amp)
|
||||||
|
val norm = ln(1.0 + a.toDouble()) / ln(1.0 + 32768.0)
|
||||||
|
return norm.toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resampleWave(values: FloatArray, target: Int): FloatArray {
|
||||||
|
if (values.isEmpty() || target <= 0) return FloatArray(target) { 0f }
|
||||||
|
if (values.size == target) return values
|
||||||
|
val out = FloatArray(target)
|
||||||
|
val step = (values.size - 1).toFloat() / (target - 1).toFloat()
|
||||||
|
var x = 0f
|
||||||
|
for (i in 0 until target) {
|
||||||
|
val idx = x.toInt()
|
||||||
|
val frac = x - idx
|
||||||
|
val a = values[idx]
|
||||||
|
val b = values[min(values.size - 1, idx + 1)]
|
||||||
|
out[i] = (a + (b - a) * frac).coerceIn(0f, 1f)
|
||||||
|
x += step
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
object AudioWaveformExtractor {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
fun extractAsync(path: String, sampleCount: Int = 120, onComplete: (FloatArray?) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
onComplete(runCatching { extract(path, sampleCount) }.getOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extract(path: String, sampleCount: Int): FloatArray? {
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
extractor.setDataSource(path)
|
||||||
|
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
|
||||||
|
val fmt = extractor.getTrackFormat(idx)
|
||||||
|
val mime = fmt.getString(MediaFormat.KEY_MIME) ?: ""
|
||||||
|
mime.startsWith("audio/")
|
||||||
|
} ?: return null
|
||||||
|
extractor.selectTrack(trackIndex)
|
||||||
|
val format = extractor.getTrackFormat(trackIndex)
|
||||||
|
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
|
||||||
|
val codec = MediaCodec.createDecoderByType(mime)
|
||||||
|
codec.configure(format, null, null, 0)
|
||||||
|
codec.start()
|
||||||
|
|
||||||
|
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||||
|
val desiredBins = sampleCount.coerceAtLeast(32)
|
||||||
|
val bins = FloatArray(desiredBins) { 0f }
|
||||||
|
val counts = IntArray(desiredBins) { 0 }
|
||||||
|
|
||||||
|
val inBuffers = codec.inputBuffers
|
||||||
|
val outInfo = MediaCodec.BufferInfo()
|
||||||
|
|
||||||
|
var sawEOS = false
|
||||||
|
while (!sawEOS) {
|
||||||
|
// Queue input
|
||||||
|
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||||
|
if (inIndex >= 0) {
|
||||||
|
val buffer = codec.getInputBuffer(inIndex) ?: inBuffers[inIndex]
|
||||||
|
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||||
|
if (sampleSize < 0) {
|
||||||
|
codec.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||||
|
} else {
|
||||||
|
val presentationTimeUs = extractor.sampleTime
|
||||||
|
codec.queueInputBuffer(inIndex, 0, sampleSize, presentationTimeUs, 0)
|
||||||
|
extractor.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dequeue output
|
||||||
|
var outIndex = codec.dequeueOutputBuffer(outInfo, 10_000)
|
||||||
|
while (outIndex >= 0) {
|
||||||
|
val outBuf = codec.getOutputBuffer(outIndex)
|
||||||
|
if (outBuf != null && outInfo.size > 0) {
|
||||||
|
outBuf.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
val shortCount = outInfo.size / 2
|
||||||
|
val shorts = ShortArray(shortCount)
|
||||||
|
outBuf.asShortBuffer().get(shorts)
|
||||||
|
|
||||||
|
// Map this buffer to bins using timestamp range
|
||||||
|
val startUs = outInfo.presentationTimeUs
|
||||||
|
val endUs = startUs + bufferDurationUs(format, outInfo.size)
|
||||||
|
val startBin = binForTime(startUs, durationUs, desiredBins)
|
||||||
|
val endBin = binForTime(endUs, durationUs, desiredBins).coerceAtMost(desiredBins - 1)
|
||||||
|
|
||||||
|
var idx = 0
|
||||||
|
for (bin in startBin..endBin) {
|
||||||
|
// aggregate portion of buffer to this bin
|
||||||
|
val window = shorts.size / max(1, (endBin - startBin + 1))
|
||||||
|
val begin = idx
|
||||||
|
val finish = min(shorts.size, idx + window)
|
||||||
|
var acc = 0.0
|
||||||
|
var cnt = 0
|
||||||
|
for (i in begin until finish) {
|
||||||
|
acc += abs(shorts[i].toInt())
|
||||||
|
cnt += 1
|
||||||
|
}
|
||||||
|
val avg = if (cnt > 0) (acc / cnt) else 0.0
|
||||||
|
val norm = (avg / 32768.0).coerceIn(0.0, 1.0).toFloat()
|
||||||
|
bins[bin] = max(bins[bin], norm)
|
||||||
|
counts[bin] += 1
|
||||||
|
idx += window
|
||||||
|
}
|
||||||
|
}
|
||||||
|
codec.releaseOutputBuffer(outIndex, false)
|
||||||
|
outIndex = codec.dequeueOutputBuffer(outInfo, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||||
|
sawEOS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codec.stop()
|
||||||
|
codec.release()
|
||||||
|
extractor.release()
|
||||||
|
|
||||||
|
// Smooth + normalize
|
||||||
|
var maxVal = 0f
|
||||||
|
for (i in bins.indices) {
|
||||||
|
if (counts[i] == 0) continue
|
||||||
|
maxVal = max(maxVal, bins[i])
|
||||||
|
}
|
||||||
|
if (maxVal <= 0f) maxVal = 1f
|
||||||
|
for (i in bins.indices) {
|
||||||
|
bins[i] = (bins[i] / maxVal).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bins
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bufferDurationUs(format: MediaFormat, bytes: Int): Long {
|
||||||
|
return try {
|
||||||
|
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||||
|
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||||
|
val samples = bytes / 2 / max(1, channels)
|
||||||
|
(samples * 1_000_000L) / max(1, sampleRate)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun binForTime(presentationUs: Long, durationUs: Long, bins: Int): Int {
|
||||||
|
if (durationUs <= 0L) return 0
|
||||||
|
val frac = presentationUs.toDouble() / durationUs.toDouble()
|
||||||
|
return (frac * bins).toInt().coerceIn(0, bins - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.location.Address
|
||||||
|
import android.location.Geocoder
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
|
||||||
|
private val geocoder = Geocoder(context, Locale.getDefault())
|
||||||
|
private val TAG = "AndroidGeocoderProvider"
|
||||||
|
|
||||||
|
override suspend fun getFromLocation(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
maxResults: Int,
|
||||||
|
liveLocationToken: Long?
|
||||||
|
): List<Address> {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
try {
|
||||||
|
val startRequest = {
|
||||||
|
geocoder.getFromLocation(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
maxResults,
|
||||||
|
object : Geocoder.GeocodeListener {
|
||||||
|
override fun onGeocode(addresses: MutableList<Address>) {
|
||||||
|
if (cont.isActive) {
|
||||||
|
val result = if (liveLocationToken == null ||
|
||||||
|
LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||||
|
) {
|
||||||
|
addresses
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
cont.resume(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(errorMessage: String?) {
|
||||||
|
if (cont.isActive) {
|
||||||
|
Log.e(TAG, "Geocode error")
|
||||||
|
cont.resume(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val started = if (liveLocationToken == null) {
|
||||||
|
startRequest()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
LiveLocationPrivacyGate.runIfAllowed(
|
||||||
|
liveLocationToken,
|
||||||
|
startRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!started && cont.isActive) cont.resume(emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (cont.isActive) cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
try {
|
||||||
|
if (liveLocationToken != null &&
|
||||||
|
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||||
|
) return emptyList()
|
||||||
|
|
||||||
|
// This legacy API blocks and cannot be cancelled. Never hold the privacy
|
||||||
|
// gate's read lock across the call: revocation must remain immediate.
|
||||||
|
val addresses = geocoder.getFromLocation(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
maxResults
|
||||||
|
) ?: emptyList()
|
||||||
|
|
||||||
|
if (liveLocationToken == null ||
|
||||||
|
LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||||
|
) {
|
||||||
|
addresses
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Geocode failed")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import com.google.android.gms.location.*
|
||||||
|
import com.google.android.gms.tasks.CancellationTokenSource
|
||||||
|
|
||||||
|
internal class FusedLocationProvider(private val context: Context) : LocationProvider {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FusedLocationProvider"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
|
||||||
|
|
||||||
|
// Map to keep track of callbacks to remove them later
|
||||||
|
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
|
||||||
|
private val activeCurrentLocationRequests = mutableSetOf<CancellationTokenSource>()
|
||||||
|
|
||||||
|
private fun hasLocationPermission(): Boolean {
|
||||||
|
return LiveLocationPrivacyGate.isEnabled &&
|
||||||
|
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||||
|
if (!hasLocationPermission()) {
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fusedLocationClient.lastLocation
|
||||||
|
.addOnSuccessListener { location ->
|
||||||
|
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
Log.e(TAG, "Error getting last-known fused location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting last-known fused location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||||
|
if (!hasLocationPermission()) {
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = CurrentLocationRequest.Builder()
|
||||||
|
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||||
|
.setDurationMillis(30000)
|
||||||
|
.build()
|
||||||
|
val cancellation = CancellationTokenSource()
|
||||||
|
|
||||||
|
synchronized(activeCurrentLocationRequests) {
|
||||||
|
activeCurrentLocationRequests.add(cancellation)
|
||||||
|
}
|
||||||
|
|
||||||
|
fusedLocationClient.getCurrentLocation(request, cancellation.token)
|
||||||
|
.addOnSuccessListener { location ->
|
||||||
|
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
Log.e(TAG, "Error getting fresh fused location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
.addOnCompleteListener {
|
||||||
|
synchronized(activeCurrentLocationRequests) {
|
||||||
|
activeCurrentLocationRequests.remove(cancellation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting fresh fused location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun requestLocationUpdates(
|
||||||
|
intervalMs: Long,
|
||||||
|
minDistanceMeters: Float,
|
||||||
|
callback: (Location) -> Unit
|
||||||
|
) {
|
||||||
|
if (!hasLocationPermission()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = LocationRequest.Builder(intervalMs)
|
||||||
|
.setMinUpdateDistanceMeters(minDistanceMeters)
|
||||||
|
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val locationCallback = object : LocationCallback() {
|
||||||
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
if (LiveLocationPrivacyGate.isEnabled) {
|
||||||
|
result.lastLocation?.let { callback(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(activeCallbacks) {
|
||||||
|
activeCallbacks[callback] = locationCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
fusedLocationClient.requestLocationUpdates(
|
||||||
|
request,
|
||||||
|
locationCallback,
|
||||||
|
Looper.getMainLooper()
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Registered fused updates")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error requesting fused updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||||
|
try {
|
||||||
|
val locationCallback = synchronized(activeCallbacks) {
|
||||||
|
activeCallbacks.remove(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationCallback != null) {
|
||||||
|
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||||
|
Log.d(TAG, "Removed fused updates")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing fused updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
try {
|
||||||
|
synchronized(activeCallbacks) {
|
||||||
|
for ((_, locationCallback) in activeCallbacks) {
|
||||||
|
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||||
|
}
|
||||||
|
activeCallbacks.clear()
|
||||||
|
}
|
||||||
|
synchronized(activeCurrentLocationRequests) {
|
||||||
|
activeCurrentLocationRequests.forEach { it.cancel() }
|
||||||
|
activeCurrentLocationRequests.clear()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Cancelled all fused updates")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error cancelling fused provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.location.Geocoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory to provide the best available geocoder.
|
||||||
|
*/
|
||||||
|
object GeocoderFactory {
|
||||||
|
fun get(context: Context): GeocoderProvider {
|
||||||
|
// If Google Play Services Geocoder is present, use it.
|
||||||
|
// Otherwise, fall back to OpenStreetMap.
|
||||||
|
return if (Geocoder.isPresent()) {
|
||||||
|
AndroidGeocoderProvider(context)
|
||||||
|
} else {
|
||||||
|
OpenStreetMapGeocoderProvider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.location.Address
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for reverse geocoding providers.
|
||||||
|
*/
|
||||||
|
interface GeocoderProvider {
|
||||||
|
/**
|
||||||
|
* Get a list of Address objects from latitude and longitude.
|
||||||
|
*/
|
||||||
|
suspend fun getFromLocation(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
maxResults: Int,
|
||||||
|
liveLocationToken: Long? = null
|
||||||
|
): List<Address>
|
||||||
|
}
|
||||||
150
app/src/main/java/com/bitchat/android/geohash/Geohash.kt
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight Geohash encoder used for Location Channels.
|
||||||
|
* Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||||
|
*
|
||||||
|
* Port of iOS implementation for 100% compatibility
|
||||||
|
*/
|
||||||
|
object Geohash {
|
||||||
|
|
||||||
|
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||||
|
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
|
||||||
|
|
||||||
|
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the provided coordinates into a geohash string.
|
||||||
|
* @param latitude Latitude in degrees (-90...90)
|
||||||
|
* @param longitude Longitude in degrees (-180...180)
|
||||||
|
* @param precision Number of geohash characters (2-12 typical). Values <= 0 return an empty string.
|
||||||
|
* @return Base32 geohash string of length `precision`.
|
||||||
|
*/
|
||||||
|
fun encode(latitude: Double, longitude: Double, precision: Int): String {
|
||||||
|
if (precision <= 0) return ""
|
||||||
|
|
||||||
|
var latInterval = -90.0 to 90.0
|
||||||
|
var lonInterval = -180.0 to 180.0
|
||||||
|
|
||||||
|
var isEven = true
|
||||||
|
var bit = 0
|
||||||
|
var ch = 0
|
||||||
|
val geohash = StringBuilder()
|
||||||
|
|
||||||
|
val lat = latitude.coerceIn(-90.0, 90.0)
|
||||||
|
val lon = longitude.coerceIn(-180.0, 180.0)
|
||||||
|
|
||||||
|
while (geohash.length < precision) {
|
||||||
|
if (isEven) {
|
||||||
|
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||||
|
if (lon >= mid) {
|
||||||
|
ch = ch or (1 shl (4 - bit))
|
||||||
|
lonInterval = mid to lonInterval.second
|
||||||
|
} else {
|
||||||
|
lonInterval = lonInterval.first to mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mid = (latInterval.first + latInterval.second) / 2
|
||||||
|
if (lat >= mid) {
|
||||||
|
ch = ch or (1 shl (4 - bit))
|
||||||
|
latInterval = mid to latInterval.second
|
||||||
|
} else {
|
||||||
|
latInterval = latInterval.first to mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEven = !isEven
|
||||||
|
if (bit < 4) {
|
||||||
|
bit += 1
|
||||||
|
} else {
|
||||||
|
geohash.append(base32Chars[ch])
|
||||||
|
bit = 0
|
||||||
|
ch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return geohash.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a geohash string to the center latitude/longitude of its cell.
|
||||||
|
* @return Pair(latitude, longitude)
|
||||||
|
*/
|
||||||
|
fun decodeToCenter(geohash: String): Pair<Double, Double> {
|
||||||
|
val b = decodeToBounds(geohash)
|
||||||
|
val latCenter = (b.latMin + b.latMax) / 2
|
||||||
|
val lonCenter = (b.lonMin + b.lonMax) / 2
|
||||||
|
return latCenter to lonCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a geohash string to bounding box (lat/lon min/max).
|
||||||
|
*/
|
||||||
|
fun decodeToBounds(geohash: String): Bounds {
|
||||||
|
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
var latInterval = -90.0 to 90.0
|
||||||
|
var lonInterval = -180.0 to 180.0
|
||||||
|
var isEven = true
|
||||||
|
|
||||||
|
geohash.lowercase().forEach { ch ->
|
||||||
|
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||||
|
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
|
||||||
|
if (isEven) {
|
||||||
|
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||||
|
if ((cd and mask) != 0) {
|
||||||
|
lonInterval = mid to lonInterval.second
|
||||||
|
} else {
|
||||||
|
lonInterval = lonInterval.first to mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mid = (latInterval.first + latInterval.second) / 2
|
||||||
|
if ((cd and mask) != 0) {
|
||||||
|
latInterval = mid to latInterval.second
|
||||||
|
} else {
|
||||||
|
latInterval = latInterval.first to mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isEven = !isEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bounds(
|
||||||
|
latMin = minOf(latInterval.first, latInterval.second),
|
||||||
|
latMax = maxOf(latInterval.first, latInterval.second),
|
||||||
|
lonMin = minOf(lonInterval.first, lonInterval.second),
|
||||||
|
lonMax = maxOf(lonInterval.first, lonInterval.second)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the 8 neighboring geohash cells at the same precision as the input.
|
||||||
|
* Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries.
|
||||||
|
*/
|
||||||
|
fun neighborsSamePrecision(geohash: String): Set<String> {
|
||||||
|
if (geohash.isEmpty()) return emptySet()
|
||||||
|
val p = geohash.length
|
||||||
|
val b = decodeToBounds(geohash)
|
||||||
|
val dLat = b.latMax - b.latMin
|
||||||
|
val dLon = b.lonMax - b.lonMin
|
||||||
|
|
||||||
|
fun wrapLon(lon: Double): Double {
|
||||||
|
var x = lon
|
||||||
|
while (x > 180.0) x -= 360.0
|
||||||
|
while (x < -180.0) x += 360.0
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
val neighbors = mutableSetOf<String>()
|
||||||
|
for (dy in -1..1) {
|
||||||
|
for (dx in -1..1) {
|
||||||
|
if (dx == 0 && dy == 0) continue // skip center
|
||||||
|
val centerLat = (b.latMin + b.latMax) / 2 + dy * dLat
|
||||||
|
val rawLonCenter = (b.lonMin + b.lonMax) / 2 + dx * dLon
|
||||||
|
val centerLon = wrapLon(rawLonCenter)
|
||||||
|
val enc = encode(centerLat.coerceIn(-90.0, 90.0), centerLon, p)
|
||||||
|
if (enc.isNotEmpty() && enc != geohash) neighbors.add(enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return neighbors
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,247 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.location.Geocoder
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a user-maintained list of bookmarked geohash channels.
|
||||||
|
* - Persistence: SharedPreferences (JSON string array)
|
||||||
|
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||||
|
*/
|
||||||
|
class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "GeohashBookmarksStore"
|
||||||
|
private const val STORE_KEY = "locationChannel.bookmarks"
|
||||||
|
private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames"
|
||||||
|
|
||||||
|
@Volatile private var INSTANCE: GeohashBookmarksStore? = null
|
||||||
|
fun getInstance(context: Context): GeohashBookmarksStore {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||||
|
fun normalize(raw: String): String {
|
||||||
|
return raw.trim().lowercase(Locale.US)
|
||||||
|
.replace("#", "")
|
||||||
|
.filter { allowedChars.contains(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val membership = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
|
||||||
|
|
||||||
|
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
|
||||||
|
|
||||||
|
// For throttling / preventing duplicate geocode lookups
|
||||||
|
private val resolving = mutableSetOf<String>()
|
||||||
|
|
||||||
|
init { load() }
|
||||||
|
|
||||||
|
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
|
||||||
|
|
||||||
|
fun toggle(geohash: String) {
|
||||||
|
val gh = normalize(geohash)
|
||||||
|
if (membership.contains(gh)) remove(gh) else add(gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(geohash: String) {
|
||||||
|
val gh = normalize(geohash)
|
||||||
|
if (gh.isEmpty() || membership.contains(gh)) return
|
||||||
|
membership.add(gh)
|
||||||
|
val updated = listOf(gh) + (_bookmarks.value)
|
||||||
|
_bookmarks.value = updated
|
||||||
|
persist(updated)
|
||||||
|
// Resolve friendly name asynchronously
|
||||||
|
resolveNameIfNeeded(gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(geohash: String) {
|
||||||
|
val gh = normalize(geohash)
|
||||||
|
if (!membership.contains(gh)) return
|
||||||
|
membership.remove(gh)
|
||||||
|
val updated = (_bookmarks.value).filterNot { it == gh }
|
||||||
|
_bookmarks.value = updated
|
||||||
|
// Remove stored name to avoid stale cache growth
|
||||||
|
val names = _bookmarkNames.value.toMutableMap()
|
||||||
|
if (names.remove(gh) != null) {
|
||||||
|
_bookmarkNames.value = names
|
||||||
|
persistNames(names)
|
||||||
|
}
|
||||||
|
persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
try {
|
||||||
|
val arrJson = prefs.getString(STORE_KEY, null)
|
||||||
|
if (!arrJson.isNullOrEmpty()) {
|
||||||
|
val listType = object : TypeToken<List<String>>() {}.type
|
||||||
|
val arr = gson.fromJson<List<String>>(arrJson, listType)
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
val ordered = mutableListOf<String>()
|
||||||
|
arr.forEach { raw ->
|
||||||
|
val gh = normalize(raw)
|
||||||
|
if (gh.isNotEmpty() && !seen.contains(gh)) {
|
||||||
|
seen.add(gh)
|
||||||
|
ordered.add(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
membership.clear(); membership.addAll(seen)
|
||||||
|
_bookmarks.value = ordered
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load bookmarks")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
|
||||||
|
if (!namesJson.isNullOrEmpty()) {
|
||||||
|
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
||||||
|
_bookmarkNames.value = dict
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load bookmark names")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist() {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(_bookmarks.value)
|
||||||
|
prefs.edit().putString(STORE_KEY, json).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistNames() {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(_bookmarkNames.value)
|
||||||
|
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Destructive Reset
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
try {
|
||||||
|
membership.clear()
|
||||||
|
_bookmarks.value = emptyList()
|
||||||
|
_bookmarkNames.value = emptyMap()
|
||||||
|
prefs.edit()
|
||||||
|
.remove(STORE_KEY)
|
||||||
|
.remove(NAMES_STORE_KEY)
|
||||||
|
.apply()
|
||||||
|
// Clear any in-flight resolutions to avoid repopulating
|
||||||
|
resolving.clear()
|
||||||
|
Log.i(TAG, "Cleared all geohash bookmarks and names")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to clear geohash bookmarks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Friendly Name Resolution
|
||||||
|
|
||||||
|
fun resolveNameIfNeeded(geohash: String) {
|
||||||
|
val gh = normalize(geohash)
|
||||||
|
if (gh.isEmpty()) return
|
||||||
|
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||||
|
if (resolving.contains(gh)) return
|
||||||
|
|
||||||
|
resolving.add(gh)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val geocoderProvider = GeocoderFactory.get(context)
|
||||||
|
val name: String? = if (gh.length <= 2) {
|
||||||
|
// Composite admin name from multiple points
|
||||||
|
val b = Geohash.decodeToBounds(gh)
|
||||||
|
val points = listOf(
|
||||||
|
Location(LocationManager.GPS_PROVIDER).apply { latitude = (b.latMin + b.latMax) / 2; longitude = (b.lonMin + b.lonMax) / 2 },
|
||||||
|
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMin },
|
||||||
|
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMax },
|
||||||
|
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMin },
|
||||||
|
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMax }
|
||||||
|
)
|
||||||
|
val admins = linkedSetOf<String>()
|
||||||
|
for (loc in points) {
|
||||||
|
try {
|
||||||
|
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||||
|
val a = list.firstOrNull()
|
||||||
|
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||||
|
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||||
|
if (admin != null) admins.add(admin)
|
||||||
|
else if (country != null) admins.add(country)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
if (admins.size >= 2) break
|
||||||
|
}
|
||||||
|
when (admins.size) {
|
||||||
|
0 -> null
|
||||||
|
1 -> admins.first()
|
||||||
|
else -> admins.elementAt(0) + " and " + admins.elementAt(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val center = Geohash.decodeToCenter(gh)
|
||||||
|
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
|
||||||
|
val a = list.firstOrNull()
|
||||||
|
pickNameForLength(gh.length, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name.isNullOrEmpty()) {
|
||||||
|
val current = _bookmarkNames.value.toMutableMap()
|
||||||
|
current[gh] = name
|
||||||
|
_bookmarkNames.value = current
|
||||||
|
persistNames(current)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Bookmark name resolution failed")
|
||||||
|
} finally {
|
||||||
|
resolving.remove(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickNameForLength(len: Int, address: android.location.Address?): String? {
|
||||||
|
if (address == null) return null
|
||||||
|
return when (len) {
|
||||||
|
in 0..2 -> address.adminArea ?: address.countryName
|
||||||
|
in 3..4 -> address.adminArea ?: address.subAdminArea ?: address.countryName
|
||||||
|
5 -> address.locality ?: address.subAdminArea ?: address.adminArea
|
||||||
|
in 6..7 -> address.subLocality ?: address.locality ?: address.adminArea
|
||||||
|
else -> address.subLocality ?: address.locality ?: address.adminArea ?: address.countryName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist(list: List<String>) {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(list)
|
||||||
|
prefs.edit().putString(STORE_KEY, json).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistNames(map: Map<String, String>) {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(map)
|
||||||
|
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
internal object GeohashNostrPrivacyPolicy {
|
||||||
|
fun livePresenceTargets(
|
||||||
|
availableChannels: Collection<GeohashChannel>,
|
||||||
|
liveLocationEnabled: Boolean,
|
||||||
|
): Set<String> {
|
||||||
|
if (!liveLocationEnabled) return emptySet()
|
||||||
|
return availableChannels
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.level.precision <= GeohashChannelLevel.CITY.precision }
|
||||||
|
.map { it.geohash }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun samplingTargets(
|
||||||
|
liveLocationGeohashes: Collection<String>,
|
||||||
|
userSelectedGeohashes: Collection<String>,
|
||||||
|
liveLocationEnabled: Boolean,
|
||||||
|
): Set<String> = buildSet {
|
||||||
|
addAll(userSelectedGeohashes)
|
||||||
|
if (liveLocationEnabled) addAll(liveLocationGeohashes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-wide, fail-closed consent gate for accessing live device location.
|
||||||
|
*
|
||||||
|
* A generation token prevents callbacks that were started under an older consent
|
||||||
|
* state from being accepted after location access is disabled or re-enabled.
|
||||||
|
*/
|
||||||
|
internal class LiveLocationAccessPolicy(
|
||||||
|
initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED,
|
||||||
|
) {
|
||||||
|
private val accessLock = ReentrantReadWriteLock()
|
||||||
|
private val generation = AtomicLong(0L)
|
||||||
|
private val _enabled = MutableStateFlow(initialEnabled)
|
||||||
|
private var accessAvailable = initialEnabled
|
||||||
|
|
||||||
|
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
|
||||||
|
val isEnabled: Boolean
|
||||||
|
get() = _enabled.value
|
||||||
|
|
||||||
|
fun update(enabled: Boolean) {
|
||||||
|
accessLock.write {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
_enabled.value = enabled
|
||||||
|
accessAvailable = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate() {
|
||||||
|
accessLock.write {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
accessAvailable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeAccess() {
|
||||||
|
accessLock.write {
|
||||||
|
if (_enabled.value && !accessAvailable) {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
accessAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun captureToken(): Long? =
|
||||||
|
accessLock.read {
|
||||||
|
val capturedGeneration = generation.get()
|
||||||
|
capturedGeneration.takeIf {
|
||||||
|
_enabled.value &&
|
||||||
|
accessAvailable &&
|
||||||
|
generation.get() == capturedGeneration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun accepts(token: Long): Boolean =
|
||||||
|
accessLock.read {
|
||||||
|
_enabled.value && accessAvailable && generation.get() == token
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
|
||||||
|
accessLock.read {
|
||||||
|
if (!_enabled.value || !accessAvailable || generation.get() != token) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DEFAULT_LIVE_LOCATION_ENABLED = false
|
||||||
|
|
||||||
|
internal object LiveLocationPrivacyGate {
|
||||||
|
private val policy = LiveLocationAccessPolicy()
|
||||||
|
private val revocationListeners = CopyOnWriteArraySet<() -> Unit>()
|
||||||
|
|
||||||
|
val enabled: StateFlow<Boolean> = policy.enabled
|
||||||
|
val isEnabled: Boolean
|
||||||
|
get() = policy.isEnabled
|
||||||
|
|
||||||
|
fun update(enabled: Boolean) {
|
||||||
|
policy.update(enabled)
|
||||||
|
notifyRevoked()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidate() {
|
||||||
|
policy.invalidate()
|
||||||
|
notifyRevoked()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun captureToken(): Long? = policy.captureToken()
|
||||||
|
fun resumeAccess() = policy.resumeAccess()
|
||||||
|
fun accepts(token: Long): Boolean = policy.accepts(token)
|
||||||
|
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
|
||||||
|
policy.runIfAllowed(token, action)
|
||||||
|
|
||||||
|
fun addRevocationListener(listener: () -> Unit) {
|
||||||
|
revocationListeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRevocationListener(listener: () -> Unit) {
|
||||||
|
revocationListeners.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyRevoked() {
|
||||||
|
revocationListeners.forEach { listener ->
|
||||||
|
runCatching(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levels of location channels mapped to geohash precisions.
|
||||||
|
* Direct port from iOS implementation for 100% compatibility
|
||||||
|
*/
|
||||||
|
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
|
||||||
|
BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes)
|
||||||
|
BLOCK(7, "Block"),
|
||||||
|
NEIGHBORHOOD(6, "Neighborhood"),
|
||||||
|
CITY(5, "City"),
|
||||||
|
PROVINCE(4, "Province"),
|
||||||
|
REGION(2, "REGION");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun allCases(): List<GeohashChannelLevel> = values().toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A computed geohash channel option.
|
||||||
|
* Direct port from iOS implementation
|
||||||
|
*/
|
||||||
|
data class GeohashChannel(
|
||||||
|
val level: GeohashChannelLevel,
|
||||||
|
val geohash: String
|
||||||
|
) {
|
||||||
|
val id: String get() = "${level.name}-$geohash"
|
||||||
|
|
||||||
|
val displayName: String get() = "${level.displayName} • $geohash"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifier for current public chat channel (mesh or a location geohash).
|
||||||
|
* Direct port from iOS implementation
|
||||||
|
*/
|
||||||
|
sealed class ChannelID {
|
||||||
|
object Mesh : ChannelID()
|
||||||
|
data class Location(val channel: GeohashChannel) : ChannelID() {
|
||||||
|
companion object {
|
||||||
|
fun fromPersisted(levelName: String, geohash: String): Location? {
|
||||||
|
return try {
|
||||||
|
val level = GeohashChannelLevel.valueOf(levelName)
|
||||||
|
Location(GeohashChannel(level, geohash))
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human readable name for UI.
|
||||||
|
*/
|
||||||
|
val displayName: String
|
||||||
|
get() = when (this) {
|
||||||
|
is Mesh -> "Mesh"
|
||||||
|
is Location -> channel.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nostr tag value for scoping (geohash), if applicable.
|
||||||
|
*/
|
||||||
|
val nostrGeohashTag: String?
|
||||||
|
get() = when (this) {
|
||||||
|
is Mesh -> null
|
||||||
|
is Location -> channel.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return when {
|
||||||
|
this is Mesh && other is Mesh -> true
|
||||||
|
this is Location && other is Location -> this.channel == other.channel
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return when (this) {
|
||||||
|
is Mesh -> "mesh".hashCode()
|
||||||
|
is Location -> channel.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,694 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import com.google.android.gms.common.ConnectionResult
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonSyntaxException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
|
* Direct port from iOS LocationChannelManager for 100% compatibility
|
||||||
|
*/
|
||||||
|
class LocationChannelManager private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LocationChannelManager"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: LocationChannelManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): LocationChannelManager {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: LocationChannelManager(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State enum matching iOS
|
||||||
|
enum class PermissionState {
|
||||||
|
DENIED,
|
||||||
|
AUTHORIZED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LocationSelectionSource {
|
||||||
|
NEARBY,
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
private val locationProvider: LocationProvider
|
||||||
|
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
|
||||||
|
private var geocodingJob: Job? = null
|
||||||
|
private val gson = Gson()
|
||||||
|
private var dataManager: com.bitchat.android.ui.DataManager? = null
|
||||||
|
private var selectedLocationSource: LocationSelectionSource? = null
|
||||||
|
private var activeLocationUpdateCallback: ((Location) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun checkSystemLocationEnabled(): Boolean {
|
||||||
|
return try {
|
||||||
|
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
|
||||||
|
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val locationStateReceiver = object : android.content.BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: android.content.Intent?) {
|
||||||
|
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
|
||||||
|
val isEnabled = checkSystemLocationEnabled()
|
||||||
|
Log.d(TAG, "System location state changed: $isEnabled")
|
||||||
|
_systemLocationEnabled.value = isEnabled
|
||||||
|
if (!isEnabled) {
|
||||||
|
clearLiveLocationState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published state for UI bindings (matching iOS @Published properties)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
private val _permissionState = MutableStateFlow(PermissionState.DENIED)
|
||||||
|
val permissionState: StateFlow<PermissionState> = _permissionState
|
||||||
|
|
||||||
|
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
|
||||||
|
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
|
||||||
|
|
||||||
|
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
|
||||||
|
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
|
||||||
|
|
||||||
|
private val _teleported = MutableStateFlow(false)
|
||||||
|
val teleported: StateFlow<Boolean> = _teleported
|
||||||
|
|
||||||
|
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
|
||||||
|
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
|
||||||
|
|
||||||
|
private val _isLoadingLocation = MutableStateFlow(false)
|
||||||
|
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
|
||||||
|
|
||||||
|
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
|
||||||
|
|
||||||
|
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
|
||||||
|
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
|
||||||
|
|
||||||
|
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
|
||||||
|
locationServicesEnabled,
|
||||||
|
systemLocationEnabled
|
||||||
|
) { appToggle, systemToggle ->
|
||||||
|
appToggle && systemToggle
|
||||||
|
}.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Choose the best location provider
|
||||||
|
val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
|
||||||
|
locationProvider = if (availability == ConnectionResult.SUCCESS) {
|
||||||
|
Log.i(TAG, "Using FusedLocationProvider (Google Play Services)")
|
||||||
|
FusedLocationProvider(context)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
|
||||||
|
SystemLocationProvider(context)
|
||||||
|
}
|
||||||
|
LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork)
|
||||||
|
|
||||||
|
// Initialize DataManager and load persisted settings
|
||||||
|
dataManager = com.bitchat.android.ui.DataManager(context)
|
||||||
|
loadLocationServicesState()
|
||||||
|
syncPermissionState()
|
||||||
|
if (!_systemLocationEnabled.value) clearLiveLocationState()
|
||||||
|
loadPersistedChannelSelection()
|
||||||
|
|
||||||
|
// Register for system location changes
|
||||||
|
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API (matching iOS interface)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable location channels (request permission if needed)
|
||||||
|
* UNIFIED: Only requests location if location services are enabled by user
|
||||||
|
*/
|
||||||
|
fun enableLocationChannels() {
|
||||||
|
if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) {
|
||||||
|
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncPermissionState() == PermissionState.AUTHORIZED) {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh available channels from current location
|
||||||
|
*/
|
||||||
|
fun refreshChannels() {
|
||||||
|
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin real-time location updates while a selector UI is visible
|
||||||
|
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
|
||||||
|
*/
|
||||||
|
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||||
|
if (syncPermissionState() != PermissionState.AUTHORIZED) {
|
||||||
|
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLocationServicesEnabled()) {
|
||||||
|
Log.w(TAG, "Cannot start live refresh - location services disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
endLiveRefresh()
|
||||||
|
LiveLocationPrivacyGate.resumeAccess()
|
||||||
|
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||||
|
val callback: (Location) -> Unit = { location ->
|
||||||
|
if (canUseLiveLocation(token)) {
|
||||||
|
onLocationUpdated(location, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeLocationUpdateCallback = callback
|
||||||
|
|
||||||
|
// Register for continuous updates from available provider
|
||||||
|
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||||
|
locationProvider.requestLocationUpdates(
|
||||||
|
intervalMs = interval,
|
||||||
|
minDistanceMeters = 5f,
|
||||||
|
callback = callback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
activeLocationUpdateCallback = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prime state immediately with last known / current location
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop periodic refreshes when selector UI is dismissed
|
||||||
|
*/
|
||||||
|
fun endLiveRefresh() {
|
||||||
|
activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates)
|
||||||
|
activeLocationUpdateCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic selection is intentionally treated as manual. GPS-derived selections must use
|
||||||
|
* [selectNearby] so their provenance can be revoked when live location is disabled.
|
||||||
|
*/
|
||||||
|
fun select(channel: ChannelID) {
|
||||||
|
when (channel) {
|
||||||
|
ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false)
|
||||||
|
is ChannelID.Location -> selectManual(channel.channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectNearby(channel: GeohashChannel): Boolean {
|
||||||
|
val isCurrentNearbyChannel = _availableChannels.value.contains(channel)
|
||||||
|
if (!isCurrentNearbyChannel || !isLocationServicesEnabled() ||
|
||||||
|
syncPermissionState() != PermissionState.AUTHORIZED
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Blocked nearby channel selection without live-location access")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
selectInternal(
|
||||||
|
ChannelID.Location(channel),
|
||||||
|
source = LocationSelectionSource.NEARBY,
|
||||||
|
teleported = false
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectManual(channel: GeohashChannel, teleported: Boolean = true) {
|
||||||
|
selectInternal(
|
||||||
|
ChannelID.Location(channel),
|
||||||
|
source = LocationSelectionSource.MANUAL,
|
||||||
|
teleported = teleported || !LiveLocationPrivacyGate.isEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable location services (user-controlled toggle)
|
||||||
|
*/
|
||||||
|
fun enableLocationServices() {
|
||||||
|
if (!LiveLocationPrivacyGate.isEnabled) {
|
||||||
|
LiveLocationPrivacyGate.update(true)
|
||||||
|
saveLocationServicesState(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have permission and system location is on, start location operations
|
||||||
|
if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable location services (user-controlled toggle)
|
||||||
|
*/
|
||||||
|
fun disableLocationServices() {
|
||||||
|
LiveLocationPrivacyGate.update(false)
|
||||||
|
saveLocationServicesState(false)
|
||||||
|
clearLiveLocationState(invalidateAccess = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if location services are enabled by the user
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Check if both the app toggle and system location are enabled
|
||||||
|
*/
|
||||||
|
fun isLocationServicesEnabled(): Boolean {
|
||||||
|
return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean {
|
||||||
|
if (_selectedChannel.value != ChannelID.Location(channel)) return false
|
||||||
|
return selectedLocationSource == LocationSelectionSource.MANUAL ||
|
||||||
|
LiveLocationPrivacyGate.captureToken() != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean =
|
||||||
|
_selectedChannel.value == ChannelID.Location(channel) &&
|
||||||
|
selectedLocationSource == LocationSelectionSource.NEARBY
|
||||||
|
|
||||||
|
fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? {
|
||||||
|
if (!isSelectedChannelLiveDerived(channel)) return null
|
||||||
|
return LiveLocationPrivacyGate.captureToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectInternal(
|
||||||
|
channel: ChannelID,
|
||||||
|
source: LocationSelectionSource?,
|
||||||
|
teleported: Boolean
|
||||||
|
) {
|
||||||
|
selectedLocationSource = source
|
||||||
|
_teleported.value = when (channel) {
|
||||||
|
ChannelID.Mesh -> false
|
||||||
|
is ChannelID.Location -> teleported
|
||||||
|
}
|
||||||
|
_selectedChannel.value = channel
|
||||||
|
saveChannelSelection(channel, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all GPS-derived in-memory state and pending work. This deliberately does not
|
||||||
|
* change the persisted soft setting when Android's hard permission or system provider is
|
||||||
|
* temporarily unavailable.
|
||||||
|
*/
|
||||||
|
private fun clearLiveLocationState(invalidateAccess: Boolean = true) {
|
||||||
|
if (invalidateAccess) LiveLocationPrivacyGate.invalidate()
|
||||||
|
cancelLiveLocationWork()
|
||||||
|
_isLoadingLocation.value = false
|
||||||
|
NostrIdentityBridge.clearGeohashIdentityCache(
|
||||||
|
_availableChannels.value.map { it.geohash }
|
||||||
|
)
|
||||||
|
_availableChannels.value = emptyList()
|
||||||
|
_locationNames.value = emptyMap()
|
||||||
|
|
||||||
|
when {
|
||||||
|
_selectedChannel.value is ChannelID.Location &&
|
||||||
|
selectedLocationSource == LocationSelectionSource.NEARBY -> {
|
||||||
|
selectInternal(ChannelID.Mesh, source = null, teleported = false)
|
||||||
|
}
|
||||||
|
_selectedChannel.value is ChannelID.Location -> {
|
||||||
|
// Manual channels remain usable for teleports, bookmarks, and DMs. Never use
|
||||||
|
// a previously retained GPS fix to classify them while live access is off.
|
||||||
|
selectedLocationSource = LocationSelectionSource.MANUAL
|
||||||
|
_teleported.value = true
|
||||||
|
saveChannelSelection(_selectedChannel.value, selectedLocationSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelLiveLocationWork() {
|
||||||
|
locationProvider.cancel()
|
||||||
|
activeLocationUpdateCallback = null
|
||||||
|
geocodingJob?.cancel()
|
||||||
|
geocodingJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Location Operations
|
||||||
|
|
||||||
|
private fun requestOneShotLocation() {
|
||||||
|
if (!isLocationServicesEnabled() ||
|
||||||
|
syncPermissionState() != PermissionState.AUTHORIZED
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "No location permission for one-shot request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveLocationPrivacyGate.resumeAccess()
|
||||||
|
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||||
|
_isLoadingLocation.value = true
|
||||||
|
|
||||||
|
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||||
|
locationProvider.getLastKnownLocation { cached ->
|
||||||
|
if (!canUseLiveLocation(token)) return@getLastKnownLocation
|
||||||
|
|
||||||
|
if (cached != null) {
|
||||||
|
onLocationUpdated(cached, token)
|
||||||
|
} else {
|
||||||
|
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||||
|
locationProvider.requestFreshLocation { fresh ->
|
||||||
|
if (!canUseLiveLocation(token)) return@requestFreshLocation
|
||||||
|
|
||||||
|
if (fresh != null) {
|
||||||
|
onLocationUpdated(fresh, token)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to get fresh location")
|
||||||
|
_isLoadingLocation.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started) _isLoadingLocation.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLocationUpdated(location: Location, token: Long) {
|
||||||
|
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||||
|
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
|
||||||
|
_isLoadingLocation.value = false
|
||||||
|
computeChannels(location, token)
|
||||||
|
reverseGeocodeIfNeeded(location, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private fun hasRuntimeLocationPermission(): Boolean {
|
||||||
|
return ActivityCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ActivityCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncPermissionState(): PermissionState {
|
||||||
|
val newState = if (hasRuntimeLocationPermission()) {
|
||||||
|
PermissionState.AUTHORIZED
|
||||||
|
} else {
|
||||||
|
PermissionState.DENIED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_permissionState.value != newState) {
|
||||||
|
_permissionState.value = newState
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState == PermissionState.DENIED) {
|
||||||
|
clearLiveLocationState()
|
||||||
|
}
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canUseLiveLocation(token: Long): Boolean {
|
||||||
|
return LiveLocationPrivacyGate.accepts(token) &&
|
||||||
|
_systemLocationEnabled.value &&
|
||||||
|
hasRuntimeLocationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeChannels(location: Location, token: Long) {
|
||||||
|
if (!canUseLiveLocation(token)) return
|
||||||
|
|
||||||
|
val levels = GeohashChannelLevel.allCases()
|
||||||
|
val result = mutableListOf<GeohashChannel>()
|
||||||
|
|
||||||
|
for (level in levels) {
|
||||||
|
val geohash = Geohash.encode(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
precision = level.precision
|
||||||
|
)
|
||||||
|
result.add(GeohashChannel(level = level, geohash = geohash))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUseLiveLocation(token)) return
|
||||||
|
_availableChannels.value = result
|
||||||
|
|
||||||
|
val selectedChannelValue = _selectedChannel.value
|
||||||
|
if (selectedChannelValue is ChannelID.Location &&
|
||||||
|
selectedLocationSource == LocationSelectionSource.NEARBY
|
||||||
|
) {
|
||||||
|
val currentGeohash = Geohash.encode(
|
||||||
|
latitude = location.latitude,
|
||||||
|
longitude = location.longitude,
|
||||||
|
precision = selectedChannelValue.channel.level.precision
|
||||||
|
)
|
||||||
|
_teleported.value = currentGeohash != selectedChannelValue.channel.geohash
|
||||||
|
} else if (selectedChannelValue is ChannelID.Mesh) {
|
||||||
|
_teleported.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reverseGeocodeIfNeeded(location: Location, token: Long) {
|
||||||
|
if (!canUseLiveLocation(token)) return
|
||||||
|
geocodingJob?.cancel()
|
||||||
|
|
||||||
|
geocodingJob = scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (!canUseLiveLocation(token)) return@launch
|
||||||
|
val addresses = geocoderProvider.getFromLocation(
|
||||||
|
location.latitude,
|
||||||
|
location.longitude,
|
||||||
|
1,
|
||||||
|
liveLocationToken = token
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isActive || !canUseLiveLocation(token)) return@launch
|
||||||
|
|
||||||
|
if (addresses.isNotEmpty()) {
|
||||||
|
val address = addresses[0]
|
||||||
|
val names = namesByLevel(address)
|
||||||
|
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||||
|
if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) {
|
||||||
|
_locationNames.value = names
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No reverse geocoding results")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e !is CancellationException) {
|
||||||
|
Log.e(TAG, "Reverse geocoding failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun namesByLevel(address: android.location.Address): Map<GeohashChannelLevel, String> {
|
||||||
|
val dict = mutableMapOf<GeohashChannelLevel, String>()
|
||||||
|
|
||||||
|
// Country
|
||||||
|
address.countryName?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.REGION] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Province (state/province or county or city)
|
||||||
|
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
|
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
|
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// City (locality)
|
||||||
|
address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.CITY] = it
|
||||||
|
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.CITY] = it
|
||||||
|
} ?: address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.CITY] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighborhood
|
||||||
|
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
|
||||||
|
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block: reuse neighborhood/locality granularity without exposing street level
|
||||||
|
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.BLOCK] = it
|
||||||
|
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.BLOCK] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channel Persistence
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current channel selection to persistent storage
|
||||||
|
*/
|
||||||
|
private fun saveChannelSelection(
|
||||||
|
channel: ChannelID,
|
||||||
|
source: LocationSelectionSource?
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val channelData = when (channel) {
|
||||||
|
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
|
||||||
|
is ChannelID.Location -> gson.toJson(
|
||||||
|
PersistedChannel(
|
||||||
|
mesh = false,
|
||||||
|
level = channel.channel.level.name,
|
||||||
|
geohash = channel.channel.geohash,
|
||||||
|
source = source?.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dataManager?.saveLastGeohashChannel(channelData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save channel selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted channel selection from storage
|
||||||
|
*/
|
||||||
|
private fun loadPersistedChannelSelection() {
|
||||||
|
try {
|
||||||
|
val channelData = dataManager?.loadLastGeohashChannel()
|
||||||
|
if (!channelData.isNullOrBlank()) {
|
||||||
|
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
|
||||||
|
val channel = persisted?.toChannel()
|
||||||
|
val source = persisted?.selectionSource()
|
||||||
|
val canRestore = channel !is ChannelID.Location ||
|
||||||
|
source == LocationSelectionSource.MANUAL ||
|
||||||
|
(LiveLocationPrivacyGate.isEnabled &&
|
||||||
|
_systemLocationEnabled.value &&
|
||||||
|
_permissionState.value == PermissionState.AUTHORIZED)
|
||||||
|
|
||||||
|
if (channel != null && canRestore) {
|
||||||
|
_selectedChannel.value = channel
|
||||||
|
selectedLocationSource = if (channel is ChannelID.Location) source else null
|
||||||
|
_teleported.value = channel is ChannelID.Location &&
|
||||||
|
source == LocationSelectionSource.MANUAL
|
||||||
|
} else {
|
||||||
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
selectedLocationSource = null
|
||||||
|
_teleported.value = false
|
||||||
|
saveChannelSelection(ChannelID.Mesh, source = null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
selectedLocationSource = null
|
||||||
|
}
|
||||||
|
} catch (e: JsonSyntaxException) {
|
||||||
|
Log.e(TAG, "Failed to parse persisted channel data")
|
||||||
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
selectedLocationSource = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load persisted channel")
|
||||||
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
selectedLocationSource = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PersistedChannel(
|
||||||
|
val mesh: Boolean,
|
||||||
|
val level: String? = null,
|
||||||
|
val geohash: String? = null,
|
||||||
|
val source: String? = null
|
||||||
|
) {
|
||||||
|
fun toChannel(): ChannelID? {
|
||||||
|
return if (mesh) {
|
||||||
|
ChannelID.Mesh
|
||||||
|
} else {
|
||||||
|
val levelName = level ?: return null
|
||||||
|
val gh = geohash ?: return null
|
||||||
|
ChannelID.Location.fromPersisted(levelName, gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectionSource(): LocationSelectionSource? {
|
||||||
|
if (mesh) return null
|
||||||
|
return source?.let {
|
||||||
|
runCatching { LocationSelectionSource.valueOf(it) }.getOrNull()
|
||||||
|
} ?: LocationSelectionSource.NEARBY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear persisted channel selection (useful for testing or reset)
|
||||||
|
*/
|
||||||
|
fun clearPersistedChannel() {
|
||||||
|
dataManager?.clearLastGeohashChannel()
|
||||||
|
_selectedChannel.value = ChannelID.Mesh
|
||||||
|
selectedLocationSource = null
|
||||||
|
_teleported.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Location Services State Persistence
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save location services enabled state to persistent storage
|
||||||
|
*/
|
||||||
|
private fun saveLocationServicesState(enabled: Boolean) {
|
||||||
|
try {
|
||||||
|
dataManager?.saveLocationServicesEnabled(enabled)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save location services state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted location services state from storage
|
||||||
|
*/
|
||||||
|
private fun loadLocationServicesState() {
|
||||||
|
try {
|
||||||
|
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
||||||
|
LiveLocationPrivacyGate.update(enabled)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load location services state")
|
||||||
|
LiveLocationPrivacyGate.update(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
fun cleanup() {
|
||||||
|
endLiveRefresh()
|
||||||
|
locationProvider.cancel()
|
||||||
|
|
||||||
|
geocodingJob?.cancel()
|
||||||
|
geocodingJob = null
|
||||||
|
|
||||||
|
// Unregister receiver
|
||||||
|
try { context.unregisterReceiver(locationStateReceiver) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.location.Location
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for location providers to support both
|
||||||
|
* System (LocationManager) and Google Play Services (FusedLocationProvider).
|
||||||
|
*/
|
||||||
|
internal interface LocationProvider {
|
||||||
|
/**
|
||||||
|
* Get the last known location from cache.
|
||||||
|
* @param callback Called with the location or null if not found/error.
|
||||||
|
*/
|
||||||
|
fun getLastKnownLocation(callback: (Location?) -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a single, fresh location update.
|
||||||
|
* @param callback Called with the location or null if failed.
|
||||||
|
*/
|
||||||
|
fun requestFreshLocation(callback: (Location?) -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request continuous location updates.
|
||||||
|
* @param intervalMs Desired interval in milliseconds.
|
||||||
|
* @param minDistanceMeters Minimum distance in meters.
|
||||||
|
* @param callback Called when location updates.
|
||||||
|
*/
|
||||||
|
fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop location updates.
|
||||||
|
* @param callback The same callback instance passed to requestLocationUpdates.
|
||||||
|
*/
|
||||||
|
fun removeLocationUpdates(callback: (Location) -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any pending one-shot location requests and cleanup resources.
|
||||||
|
*/
|
||||||
|
fun cancel()
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.location.Address
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.net.OkHttpProvider
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class OpenStreetMapGeocoderProvider : GeocoderProvider {
|
||||||
|
private val TAG = "OSMGeocoderProvider"
|
||||||
|
private val gson = Gson()
|
||||||
|
private val userAgent = "Bitchat-Android/1.0"
|
||||||
|
|
||||||
|
override suspend fun getFromLocation(
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
maxResults: Int,
|
||||||
|
liveLocationToken: Long?
|
||||||
|
): List<Address> {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
val lang = Locale.getDefault().toLanguageTag()
|
||||||
|
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
val call = OkHttpProvider.httpClient().newCall(request)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation { call.cancel() }
|
||||||
|
val enqueueRequest = {
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isActive) {
|
||||||
|
Log.w(TAG, "OSM geocoding request failed")
|
||||||
|
continuation.resume(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val addresses = response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
Log.w(TAG, "OSM geocoding request returned ${it.code}")
|
||||||
|
return@use emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = it.body?.string()
|
||||||
|
if (body.isNullOrEmpty()) return@use emptyList()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
|
||||||
|
if (osmResponse?.address == null) emptyList()
|
||||||
|
else listOf(mapToAddress(osmResponse, latitude, longitude))
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "OSM geocoding response could not be parsed")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (continuation.isActive) continuation.resume(addresses)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val started = if (liveLocationToken == null) {
|
||||||
|
enqueueRequest()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
LiveLocationPrivacyGate.runIfAllowed(
|
||||||
|
liveLocationToken,
|
||||||
|
enqueueRequest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!started && continuation.isActive) {
|
||||||
|
continuation.resume(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
|
||||||
|
val address = Address(Locale.getDefault())
|
||||||
|
address.latitude = lat
|
||||||
|
address.longitude = lon
|
||||||
|
|
||||||
|
val a = res.address ?: return address
|
||||||
|
|
||||||
|
address.countryName = a.country
|
||||||
|
address.adminArea = a.state
|
||||||
|
address.subAdminArea = a.county
|
||||||
|
|
||||||
|
// City logic similar to Google's mapping
|
||||||
|
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
|
||||||
|
|
||||||
|
// Neighborhood logic
|
||||||
|
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
|
||||||
|
|
||||||
|
address.postalCode = a.postcode
|
||||||
|
address.thoroughfare = a.road
|
||||||
|
|
||||||
|
// Feature name
|
||||||
|
address.featureName = res.name
|
||||||
|
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data classes for JSON parsing
|
||||||
|
private data class OsmResponse(
|
||||||
|
val name: String?,
|
||||||
|
val display_name: String?,
|
||||||
|
val address: OsmAddress?
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class OsmAddress(
|
||||||
|
val country: String?,
|
||||||
|
val state: String?,
|
||||||
|
val county: String?,
|
||||||
|
val city: String?,
|
||||||
|
val town: String?,
|
||||||
|
val village: String?,
|
||||||
|
val hamlet: String?,
|
||||||
|
val suburb: String?,
|
||||||
|
val neighbourhood: String?,
|
||||||
|
val residential: String?,
|
||||||
|
val quarter: String?,
|
||||||
|
val postcode: String?,
|
||||||
|
val road: String?
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,261 @@
|
|||||||
|
package com.bitchat.android.geohash
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
|
||||||
|
internal class SystemLocationProvider(private val context: Context) : LocationProvider {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SystemLocationProvider"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
|
|
||||||
|
// Map to keep track of listeners to unregister them later
|
||||||
|
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
|
||||||
|
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
|
||||||
|
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
|
||||||
|
private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>()
|
||||||
|
|
||||||
|
private fun hasLocationPermission(): Boolean {
|
||||||
|
return LiveLocationPrivacyGate.isEnabled &&
|
||||||
|
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
|
||||||
|
if (!hasLocationPermission()) {
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var bestLocation: Location? = null
|
||||||
|
val providers = locationManager.getProviders(true)
|
||||||
|
for (provider in providers) {
|
||||||
|
val location = locationManager.getLastKnownLocation(provider)
|
||||||
|
if (location != null) {
|
||||||
|
if (bestLocation == null || location.time > bestLocation.time) {
|
||||||
|
bestLocation = location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting last-known location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun requestFreshLocation(callback: (Location?) -> Unit) {
|
||||||
|
if (!hasLocationPermission()) {
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val providers = listOf(
|
||||||
|
LocationManager.GPS_PROVIDER,
|
||||||
|
LocationManager.NETWORK_PROVIDER,
|
||||||
|
LocationManager.PASSIVE_PROVIDER
|
||||||
|
)
|
||||||
|
|
||||||
|
var providerFound = false
|
||||||
|
for (provider in providers) {
|
||||||
|
if (locationManager.isProviderEnabled(provider)) {
|
||||||
|
Log.d(TAG, "Requesting fresh location from $provider")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val cancellationSignal = CancellationSignal()
|
||||||
|
synchronized(activeOneShotCancellationSignals) {
|
||||||
|
activeOneShotCancellationSignals[callback] = cancellationSignal
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationManager.getCurrentLocation(
|
||||||
|
provider,
|
||||||
|
cancellationSignal,
|
||||||
|
context.mainExecutor
|
||||||
|
) { location ->
|
||||||
|
synchronized(activeOneShotCancellationSignals) {
|
||||||
|
activeOneShotCancellationSignals.remove(callback)
|
||||||
|
}
|
||||||
|
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
synchronized(activeOneShotCancellationSignals) {
|
||||||
|
activeOneShotCancellationSignals.remove(callback)
|
||||||
|
}
|
||||||
|
cancellationSignal.cancel()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For older versions, use requestSingleUpdate with timeout mechanism
|
||||||
|
val timeoutRunnable = Runnable {
|
||||||
|
Log.w(TAG, "Location request timed out")
|
||||||
|
synchronized(activeOneShotListeners) {
|
||||||
|
val listener = activeOneShotListeners.remove(callback)
|
||||||
|
activeOneShotRunnables.remove(callback)
|
||||||
|
if (listener != null) {
|
||||||
|
try {
|
||||||
|
locationManager.removeUpdates(listener)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing timed-out listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val listener = object : LocationListener {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
synchronized(activeOneShotListeners) {
|
||||||
|
activeOneShotListeners.remove(callback)
|
||||||
|
val runnable = activeOneShotRunnables.remove(callback)
|
||||||
|
if (runnable != null) {
|
||||||
|
handler.removeCallbacks(runnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationManager.removeUpdates(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing updates in callback")
|
||||||
|
}
|
||||||
|
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
|
||||||
|
}
|
||||||
|
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||||
|
override fun onProviderEnabled(provider: String) {}
|
||||||
|
override fun onProviderDisabled(provider: String) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(activeOneShotListeners) {
|
||||||
|
activeOneShotListeners[callback] = listener
|
||||||
|
activeOneShotRunnables[callback] = timeoutRunnable
|
||||||
|
}
|
||||||
|
|
||||||
|
locationManager.requestSingleUpdate(provider, listener, null)
|
||||||
|
handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout
|
||||||
|
}
|
||||||
|
providerFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerFound) {
|
||||||
|
Log.w(TAG, "No location providers available for fresh location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error requesting fresh location")
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun requestLocationUpdates(
|
||||||
|
intervalMs: Long,
|
||||||
|
minDistanceMeters: Float,
|
||||||
|
callback: (Location) -> Unit
|
||||||
|
) {
|
||||||
|
if (!hasLocationPermission()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val listener = object : LocationListener {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
if (LiveLocationPrivacyGate.isEnabled) callback(location)
|
||||||
|
}
|
||||||
|
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
|
||||||
|
override fun onProviderEnabled(provider: String) {}
|
||||||
|
override fun onProviderDisabled(provider: String) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the listener so we can remove it later
|
||||||
|
synchronized(activeListeners) {
|
||||||
|
activeListeners[callback] = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||||
|
var registered = false
|
||||||
|
|
||||||
|
for (provider in providers) {
|
||||||
|
if (locationManager.isProviderEnabled(provider)) {
|
||||||
|
locationManager.requestLocationUpdates(
|
||||||
|
provider,
|
||||||
|
intervalMs,
|
||||||
|
minDistanceMeters,
|
||||||
|
listener
|
||||||
|
)
|
||||||
|
registered = true
|
||||||
|
Log.d(TAG, "Registered updates for $provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registered) {
|
||||||
|
Log.w(TAG, "No providers enabled for continuous updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error requesting location updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeLocationUpdates(callback: (Location) -> Unit) {
|
||||||
|
try {
|
||||||
|
val listener = synchronized(activeListeners) {
|
||||||
|
activeListeners.remove(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
locationManager.removeUpdates(listener)
|
||||||
|
Log.d(TAG, "Removed location updates")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error removing updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
try {
|
||||||
|
// Cancel continuous updates
|
||||||
|
synchronized(activeListeners) {
|
||||||
|
for ((_, listener) in activeListeners) {
|
||||||
|
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
activeListeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel one-shot requests
|
||||||
|
synchronized(activeOneShotListeners) {
|
||||||
|
for ((_, listener) in activeOneShotListeners) {
|
||||||
|
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
activeOneShotListeners.clear()
|
||||||
|
|
||||||
|
for ((_, runnable) in activeOneShotRunnables) {
|
||||||
|
handler.removeCallbacks(runnable)
|
||||||
|
}
|
||||||
|
activeOneShotRunnables.clear()
|
||||||
|
}
|
||||||
|
synchronized(activeOneShotCancellationSignals) {
|
||||||
|
activeOneShotCancellationSignals.values.forEach { it.cancel() }
|
||||||
|
activeOneShotCancellationSignals.clear()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Cancelled all system location requests")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error cancelling system provider")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
323
app/src/main/java/com/bitchat/android/hotspot/ApkWebServer.kt
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
package com.bitchat.android.hotspot
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import fi.iki.elonen.NanoHTTPD
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot.
|
||||||
|
* Based on NanoHTTPD.
|
||||||
|
*/
|
||||||
|
class ApkWebServer(
|
||||||
|
private val context: Context,
|
||||||
|
private val apkFile: File,
|
||||||
|
private val port: Int = DEFAULT_PORT
|
||||||
|
) : NanoHTTPD(port) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ApkWebServer"
|
||||||
|
const val DEFAULT_PORT = 9999
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appVersion: String by lazy {
|
||||||
|
try {
|
||||||
|
context.packageManager
|
||||||
|
.getPackageArchiveInfo(apkFile.absolutePath, 0)
|
||||||
|
?.versionName
|
||||||
|
?: "Unknown"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the HTML landing page (generated once, reused for all requests)
|
||||||
|
private val cachedHtml: String by lazy {
|
||||||
|
generateLandingPageHtml()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serve(session: IHTTPSession): Response {
|
||||||
|
val uri = session.uri ?: "/"
|
||||||
|
|
||||||
|
Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
|
||||||
|
|
||||||
|
return when {
|
||||||
|
uri == "/bitchat.apk" -> {
|
||||||
|
serveApk()
|
||||||
|
}
|
||||||
|
uri == "/favicon.ico" -> {
|
||||||
|
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
serveLandingPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the APK file.
|
||||||
|
*/
|
||||||
|
private fun serveApk(): Response {
|
||||||
|
return try {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Log.e(TAG, "APK file not found: ${apkFile.path}")
|
||||||
|
return newFixedLengthResponse(
|
||||||
|
Response.Status.NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
"APK file not found"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)")
|
||||||
|
|
||||||
|
val inputStream = FileInputStream(apkFile)
|
||||||
|
val response = newFixedLengthResponse(
|
||||||
|
Response.Status.OK,
|
||||||
|
"application/vnd.android.package-archive",
|
||||||
|
inputStream,
|
||||||
|
apkFile.length()
|
||||||
|
)
|
||||||
|
|
||||||
|
response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"")
|
||||||
|
response.addHeader("Accept-Ranges", "bytes")
|
||||||
|
|
||||||
|
response
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error serving APK", e)
|
||||||
|
newFixedLengthResponse(
|
||||||
|
Response.Status.INTERNAL_ERROR,
|
||||||
|
"text/plain",
|
||||||
|
"Error serving APK: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the HTML landing page.
|
||||||
|
*/
|
||||||
|
private fun serveLandingPage(): Response {
|
||||||
|
return newFixedLengthResponse(
|
||||||
|
Response.Status.OK,
|
||||||
|
"text/html",
|
||||||
|
cachedHtml
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML landing page.
|
||||||
|
*/
|
||||||
|
private fun generateLandingPageHtml(): String {
|
||||||
|
val apkSizeMb = apkFile.length() / 1024 / 1024
|
||||||
|
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Download BitChat</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px 30px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 18px 40px;
|
||||||
|
border-radius: 50px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
text-align: left;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions ol {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #856404;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">🔒</div>
|
||||||
|
<h1>BitChat</h1>
|
||||||
|
<p class="subtitle">Secure Mesh Messaging</p>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-label">Version</div>
|
||||||
|
<div class="info-value">$appVersion</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-label">Size</div>
|
||||||
|
<div class="info-value">${apkSizeMb} MB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/bitchat.apk" class="download-button">
|
||||||
|
📥 Download BitChat
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<h3>📱 Installation Instructions</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Tap the download button above</li>
|
||||||
|
<li>Wait for the download to complete</li>
|
||||||
|
<li>Open the downloaded APK file</li>
|
||||||
|
<li>If prompted, enable "Install from unknown sources" for your browser</li>
|
||||||
|
<li>Follow the installation prompts</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ Note:</strong>
|
||||||
|
If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server.
|
||||||
|
*/
|
||||||
|
fun startServer() {
|
||||||
|
try {
|
||||||
|
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
|
||||||
|
Log.d(TAG, "Web server started on port $port")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start web server", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server.
|
||||||
|
*/
|
||||||
|
fun stopServer() {
|
||||||
|
try {
|
||||||
|
stop()
|
||||||
|
Log.d(TAG, "Web server stopped")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error stopping web server", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
706
app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
Normal file
@ -0,0 +1,706 @@
|
|||||||
|
package com.bitchat.android.hotspot
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitchat.android.R
|
||||||
|
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||||
|
import com.bitchat.android.ui.theme.BitchatTheme
|
||||||
|
import com.bitchat.android.util.UniversalApkManager
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity for managing Wi-Fi P2P hotspot for offline APK sharing.
|
||||||
|
* Pure Compose implementation, no fragments.
|
||||||
|
*/
|
||||||
|
class HotspotActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
private const val TAG = "HotspotActivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: HotspotViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Get APK path from intent
|
||||||
|
val apkPath = intent.getStringExtra(EXTRA_APK_PATH)
|
||||||
|
val apkFile = if (apkPath != null) {
|
||||||
|
File(apkPath)
|
||||||
|
} else {
|
||||||
|
// Fallback: Try to get cached APK
|
||||||
|
UniversalApkManager(this).getCachedApk()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apkFile == null || !apkFile.exists()) {
|
||||||
|
// No APK available, show error and finish
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
BitchatTheme {
|
||||||
|
HotspotScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
apkFile = apkFile,
|
||||||
|
onClose = { finish() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Handle notification action to stop hotspot
|
||||||
|
if (intent.action == "STOP_HOTSPOT") {
|
||||||
|
viewModel.stopHotspot()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HotspotScreen(
|
||||||
|
viewModel: HotspotViewModel,
|
||||||
|
apkFile: File,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Share BitChat",
|
||||||
|
fontFamily = BitchatFontFamily
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Crossfade(
|
||||||
|
targetState = state,
|
||||||
|
label = "HotspotStateCrossfade",
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
|
) { currentState ->
|
||||||
|
when (currentState) {
|
||||||
|
is HotspotViewModel.HotspotState.Intro -> {
|
||||||
|
IntroScreen(
|
||||||
|
onStartHotspot = { viewModel.startHotspot(apkFile) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is HotspotViewModel.HotspotState.Starting -> {
|
||||||
|
LoadingScreen()
|
||||||
|
}
|
||||||
|
is HotspotViewModel.HotspotState.ConfirmDisconnect -> {
|
||||||
|
ExistingGroupConfirmation(
|
||||||
|
onConfirm = viewModel::confirmDisconnectAndStart,
|
||||||
|
onCancel = viewModel::cancelDisconnect
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is HotspotViewModel.HotspotState.Active -> {
|
||||||
|
ActiveHotspotScreen(state = currentState)
|
||||||
|
}
|
||||||
|
is HotspotViewModel.HotspotState.Error -> {
|
||||||
|
ErrorScreen(
|
||||||
|
message = currentState.message,
|
||||||
|
onRetry = { viewModel.resetToIntro() },
|
||||||
|
onClose = onClose
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExistingGroupConfirmation(
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text(stringResource(R.string.hotspot_disconnect_title)) },
|
||||||
|
text = { Text(stringResource(R.string.hotspot_disconnect_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.hotspot_disconnect_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onCancel) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun IntroScreen(onStartHotspot: () -> Unit) {
|
||||||
|
val requiredPermissions = remember { HotspotPermissions.requiredForSdk() }
|
||||||
|
val permissionState = rememberMultiplePermissionsState(requiredPermissions) { results ->
|
||||||
|
if (requiredPermissions.all { results[it] == true }) {
|
||||||
|
onStartHotspot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Wifi,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(80.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Offline App Sharing",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "How it works:",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
InfoItem("1. Your device creates a Wi-Fi hotspot")
|
||||||
|
InfoItem("2. Others connect to your hotspot")
|
||||||
|
InfoItem("3. They scan a QR code or enter a URL")
|
||||||
|
InfoItem("4. BitChat downloads directly to their device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission rationale (if needed)
|
||||||
|
if (!permissionState.allPermissionsGranted && permissionState.shouldShowRationale) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ℹ️ Permission Required",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
Build.VERSION.SDK_INT >= HotspotPermissions.ANDROID_17_API_LEVEL ->
|
||||||
|
"BitChat needs nearby devices and local network access to create a Wi-Fi hotspot and serve the app to connected devices."
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
|
||||||
|
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
|
||||||
|
else ->
|
||||||
|
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚠️ Note",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Check permission before starting hotspot
|
||||||
|
if (permissionState.allPermissionsGranted) {
|
||||||
|
// No permission needed or already granted
|
||||||
|
onStartHotspot()
|
||||||
|
} else {
|
||||||
|
// Request permission (auto-start handled by onPermissionResult callback)
|
||||||
|
permissionState.launchMultiplePermissionRequest()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
// Starting the hotspot is the user's action. Android will ask
|
||||||
|
// for the required permission only when it has not already
|
||||||
|
// been granted.
|
||||||
|
text = "Start Hotspot",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InfoItem(text: String) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "•",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingScreen() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Starting hotspot...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("Wi-Fi", "Website")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Status banner
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Hotspot Active",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${state.connectedPeers} device(s) connected",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Wifi,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
TabRow(
|
||||||
|
selectedTabIndex = selectedTab,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> WifiTabContent(
|
||||||
|
ssid = state.ssid,
|
||||||
|
password = state.password
|
||||||
|
)
|
||||||
|
1 -> WebsiteTabContent(
|
||||||
|
ipAddress = state.ipAddress,
|
||||||
|
port = state.port
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WifiTabContent(ssid: String, password: String) {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Step 1: Connect to Wi-Fi",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Have others scan this QR code to connect:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// QR Code
|
||||||
|
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
|
||||||
|
val wifiQr = remember(ssid, password, qrSize) {
|
||||||
|
QrCodeGenerator.generateWifiQr(ssid, password, qrSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wifiQr != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = wifiQr.asImageBitmap(),
|
||||||
|
contentDescription = "Wi-Fi QR Code",
|
||||||
|
modifier = Modifier.size(280.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Or enter manually:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSID
|
||||||
|
CredentialCard(
|
||||||
|
label = "Network Name (SSID)",
|
||||||
|
value = ssid,
|
||||||
|
onCopy = {
|
||||||
|
clipboardManager.setText(AnnotatedString(ssid))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password
|
||||||
|
CredentialCard(
|
||||||
|
label = "Password",
|
||||||
|
value = password,
|
||||||
|
onCopy = {
|
||||||
|
clipboardManager.setText(AnnotatedString(password))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WebsiteTabContent(ipAddress: String, port: Int) {
|
||||||
|
val url = "http://$ipAddress:$port"
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Step 2: Download BitChat",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "After connecting to the Wi-Fi, scan this QR code:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// QR Code
|
||||||
|
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
|
||||||
|
val urlQr = remember(url, qrSize) {
|
||||||
|
QrCodeGenerator.generateUrlQr(url, qrSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlQr != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = urlQr.asImageBitmap(),
|
||||||
|
contentDescription = "Website URL QR Code",
|
||||||
|
modifier = Modifier.size(280.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Or open in browser:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// URL
|
||||||
|
CredentialCard(
|
||||||
|
label = "Website URL",
|
||||||
|
value = url,
|
||||||
|
onCopy = {
|
||||||
|
clipboardManager.setText(AnnotatedString(url))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "📱 Instructions",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "1. Make sure you're connected to the Wi-Fi network above\n" +
|
||||||
|
"2. Open a web browser on your device\n" +
|
||||||
|
"3. Visit the URL above or scan the QR code\n" +
|
||||||
|
"4. Tap 'Download BitChat'\n" +
|
||||||
|
"5. Install the downloaded APK",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CredentialCard(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onCopy: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onCopy) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy",
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorScreen(
|
||||||
|
message: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "❌",
|
||||||
|
fontSize = 64.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Error",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Try Again")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onClose) {
|
||||||
|
Text("Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
879
app/src/main/java/com/bitchat/android/hotspot/HotspotManager.kt
Normal file
@ -0,0 +1,879 @@
|
|||||||
|
package com.bitchat.android.hotspot
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.wifi.p2p.WifiP2pConfig
|
||||||
|
import android.net.wifi.p2p.WifiP2pGroup
|
||||||
|
import android.net.wifi.p2p.WifiP2pManager
|
||||||
|
import android.net.wifi.p2p.WifiP2pManager.*
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing.
|
||||||
|
* Based on Briar's implementation.
|
||||||
|
*/
|
||||||
|
class HotspotManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HotspotMgr"
|
||||||
|
|
||||||
|
// Group info polling interval
|
||||||
|
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
|
||||||
|
|
||||||
|
// Give up if the group never forms within this window after creation succeeded
|
||||||
|
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
|
||||||
|
|
||||||
|
// SSID and password configuration
|
||||||
|
private const val SSID_SUFFIX_LENGTH = 8
|
||||||
|
private const val PASSWORD_LENGTH = 16
|
||||||
|
|
||||||
|
// Records the group we created so a later run can tell our own orphan apart
|
||||||
|
// from a group belonging to Cast, Android Auto or Quick Share.
|
||||||
|
private const val PREFS_NAME = "hotspot"
|
||||||
|
private const val KEY_OWNED_GROUP = "owned_group_name"
|
||||||
|
|
||||||
|
// Characters to use for random generation (excluding confusing ones)
|
||||||
|
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
|
||||||
|
}
|
||||||
|
|
||||||
|
private val wifiP2pManager: WifiP2pManager? =
|
||||||
|
context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
|
||||||
|
|
||||||
|
private var channel: Channel? = null
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val random = SecureRandom()
|
||||||
|
|
||||||
|
private var currentGroup: WifiP2pGroup? = null
|
||||||
|
private var callback: HotspotCallback? = null
|
||||||
|
private var isStarting = false
|
||||||
|
private var hasNotifiedStarted = false // Track if we've notified the callback
|
||||||
|
private var isReceiverRegistered = false // Track receiver registration to prevent leaks
|
||||||
|
|
||||||
|
// Set once our own createGroup command is accepted, and re-checked against every
|
||||||
|
// group snapshot afterwards. stopHotspot() only calls removeGroup() when this is
|
||||||
|
// true: removal is device-scoped, so issuing it when the group on the framework
|
||||||
|
// is not ours could only tear down another app's session (Cast, Android Auto,
|
||||||
|
// Quick Share).
|
||||||
|
private var createdGroup = false
|
||||||
|
|
||||||
|
// Framework-reported name of the group this session hosts, once known. Null while
|
||||||
|
// the group is still forming, when a null snapshot carries no information.
|
||||||
|
private var hostedGroupName: String? = null
|
||||||
|
|
||||||
|
// Name of the foreign group the user explicitly agreed to disconnect, or null.
|
||||||
|
// Consent is per-group: a group with a different name asks again.
|
||||||
|
private var confirmedReplacementName: String? = null
|
||||||
|
|
||||||
|
// stopHotspot() can be reached again while its group query/removal is still in
|
||||||
|
// flight. Later callers wait for that same teardown instead of releasing the
|
||||||
|
// Wi-Fi Aware lease early.
|
||||||
|
private var teardownInProgress = false
|
||||||
|
private val teardownCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
// Saved credentials for reconnection
|
||||||
|
private var savedSsid: String? = null
|
||||||
|
private var savedPassword: String? = null
|
||||||
|
|
||||||
|
// Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives
|
||||||
|
private var lastP2pState: Int? = null
|
||||||
|
|
||||||
|
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
|
||||||
|
|
||||||
|
/** Network name of the last group this app created, surviving process death. */
|
||||||
|
private var ownedGroupName: String?
|
||||||
|
get() = prefs.getString(KEY_OWNED_GROUP, null)
|
||||||
|
set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply()
|
||||||
|
|
||||||
|
// Broadcast receiver for Wi-Fi P2P events
|
||||||
|
private val broadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
|
||||||
|
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
|
||||||
|
lastP2pState = state
|
||||||
|
Log.d(TAG, "Wi-Fi P2P state changed: $state")
|
||||||
|
|
||||||
|
// Wi-Fi Direct going away is terminal for this session: without it
|
||||||
|
// the group cannot form, and any group already up is now dead.
|
||||||
|
if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) {
|
||||||
|
Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot")
|
||||||
|
failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
|
||||||
|
Log.d(TAG, "Wi-Fi P2P connection changed")
|
||||||
|
requestGroupInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the Wi-Fi P2P hotspot.
|
||||||
|
*
|
||||||
|
* @param confirmedReplacementName name of the foreign Wi-Fi Direct group the
|
||||||
|
* user has confirmed may be disconnected, as previously reported through
|
||||||
|
* [HotspotCallback.onExistingGroupConflict]. When null (or when the group
|
||||||
|
* present no longer matches), a foreign group is reported instead of touched.
|
||||||
|
*/
|
||||||
|
fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) {
|
||||||
|
if (isStarting) {
|
||||||
|
Log.w(TAG, "Hotspot already starting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wifiP2pManager == null) {
|
||||||
|
Log.e(TAG, "Wi-Fi P2P not available on this device")
|
||||||
|
callback.onError("Wi-Fi Direct not supported on this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val missingPermissions = HotspotPermissions.missingFrom(context)
|
||||||
|
if (missingPermissions.isNotEmpty()) {
|
||||||
|
Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions")
|
||||||
|
val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) {
|
||||||
|
"Local network permission is required to share the app over the hotspot"
|
||||||
|
} else {
|
||||||
|
"Nearby Wi-Fi permission is required to start the hotspot"
|
||||||
|
}
|
||||||
|
callback.onError(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callback = callback
|
||||||
|
this.confirmedReplacementName = confirmedReplacementName
|
||||||
|
isStarting = true
|
||||||
|
|
||||||
|
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
|
||||||
|
|
||||||
|
// Register broadcast receiver (only if not already registered)
|
||||||
|
if (!isReceiverRegistered) {
|
||||||
|
val intentFilter = IntentFilter().apply {
|
||||||
|
addAction(WIFI_P2P_STATE_CHANGED_ACTION)
|
||||||
|
addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION)
|
||||||
|
}
|
||||||
|
context.registerReceiver(broadcastReceiver, intentFilter)
|
||||||
|
isReceiverRegistered = true
|
||||||
|
Log.d(TAG, "Broadcast receiver registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire locks
|
||||||
|
acquireLocks()
|
||||||
|
|
||||||
|
// Load or generate credentials
|
||||||
|
if (savedSsid == null || savedPassword == null) {
|
||||||
|
savedSsid = generateSsid()
|
||||||
|
savedPassword = generatePassword()
|
||||||
|
Log.d(TAG, "Generated new credentials: SSID=$savedSsid")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start P2P framework (retries reuse this one channel)
|
||||||
|
startWifiP2pFramework()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the hotspot.
|
||||||
|
*
|
||||||
|
* @param onTeardownComplete invoked once the framework has acknowledged the
|
||||||
|
* removal of our group (or immediately when this session created none). Lets
|
||||||
|
* the caller hold the Wi-Fi Aware radio back until the P2P group is gone.
|
||||||
|
*/
|
||||||
|
fun stopHotspot(onTeardownComplete: (() -> Unit)? = null) {
|
||||||
|
Log.d(TAG, "Stopping hotspot")
|
||||||
|
|
||||||
|
onTeardownComplete?.let(teardownCallbacks::add)
|
||||||
|
if (teardownInProgress) {
|
||||||
|
Log.d(TAG, "Teardown already in progress; chaining completion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isStarting = false
|
||||||
|
hasNotifiedStarted = false
|
||||||
|
|
||||||
|
// Stop group info polling
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
|
||||||
|
// Detach the channel first so any in-flight listener sees the hotspot as stopped,
|
||||||
|
// then remove the group and close the channel once the framework has replied.
|
||||||
|
val staleChannel = channel
|
||||||
|
channel = null
|
||||||
|
|
||||||
|
val hadOwnGroup = createdGroup
|
||||||
|
val expectedGroupName = hostedGroupName ?: if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
|
) {
|
||||||
|
savedSsid
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
createdGroup = false
|
||||||
|
hostedGroupName = null
|
||||||
|
|
||||||
|
var teardownAction: (() -> Unit)? = null
|
||||||
|
if (staleChannel != null && hadOwnGroup) {
|
||||||
|
teardownInProgress = true
|
||||||
|
teardownAction = {
|
||||||
|
removeOwnGroupIfStillPresent(staleChannel, expectedGroupName)
|
||||||
|
}
|
||||||
|
} else if (staleChannel != null) {
|
||||||
|
// This session created nothing, so there is nothing of ours to remove.
|
||||||
|
// removeGroup() here is exactly the bug this change fixes: device-scoped
|
||||||
|
// removal would disconnect whatever group another app has running.
|
||||||
|
closeChannel(staleChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release locks
|
||||||
|
releaseLocks()
|
||||||
|
|
||||||
|
// Unregister receiver (only if registered)
|
||||||
|
if (isReceiverRegistered) {
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(broadcastReceiver)
|
||||||
|
isReceiverRegistered = false
|
||||||
|
Log.d(TAG, "Broadcast receiver unregistered")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.w(TAG, "Receiver was not registered", e)
|
||||||
|
isReceiverRegistered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGroup = null
|
||||||
|
callback = null
|
||||||
|
|
||||||
|
if (teardownAction != null) {
|
||||||
|
teardownAction.invoke()
|
||||||
|
} else {
|
||||||
|
finishTeardown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-check the device-scoped group immediately before removing it. The last poll
|
||||||
|
* is only a snapshot: our group may have disappeared and another app may have
|
||||||
|
* claimed Wi-Fi Direct before stop was requested.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun removeOwnGroupIfStillPresent(ch: Channel, expectedGroupName: String?) {
|
||||||
|
val manager = wifiP2pManager ?: run {
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.requestGroupInfo(ch) { group ->
|
||||||
|
val stillOurs = HotspotStartupPolicy.isExpectedHostedGroup(
|
||||||
|
existingGroupName = group?.networkName,
|
||||||
|
isGroupOwner = group?.isGroupOwner == true,
|
||||||
|
expectedGroupName = expectedGroupName
|
||||||
|
)
|
||||||
|
if (!stillOurs) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Current group '${group?.networkName}' is not ours " +
|
||||||
|
"('$expectedGroupName'); leaving it alone"
|
||||||
|
)
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
return@requestGroupInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.removeGroup(ch, object : ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
Log.d(TAG, "Group removed successfully")
|
||||||
|
clearOwnedGroupNameIfMatches(expectedGroupName)
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
Log.w(TAG, "Failed to remove group: $reason")
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e)
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while confirming group ownership", e)
|
||||||
|
closeChannel(ch)
|
||||||
|
finishTeardown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearOwnedGroupNameIfMatches(removedGroupName: String?) {
|
||||||
|
if (HotspotStartupPolicy.shouldClearOwnedGroupName(ownedGroupName, removedGroupName)) {
|
||||||
|
ownedGroupName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishTeardown() {
|
||||||
|
teardownInProgress = false
|
||||||
|
val callbacks = teardownCallbacks.toList()
|
||||||
|
teardownCallbacks.clear()
|
||||||
|
callbacks.forEach { it.invoke() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release the channel's binder registration with WifiP2pService. Without this the
|
||||||
|
* registration survives until the process dies, and every start/stop cycle adds
|
||||||
|
* another stale client to the framework's list.
|
||||||
|
*/
|
||||||
|
private fun closeChannel(channelToClose: Channel) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
channelToClose.close()
|
||||||
|
Log.d(TAG, "P2P channel closed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error closing P2P channel", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current connection information.
|
||||||
|
*/
|
||||||
|
fun getConnectionInfo(): ConnectionInfo? {
|
||||||
|
val group = currentGroup ?: return null
|
||||||
|
val ipAddress = getAccessPointAddress()
|
||||||
|
|
||||||
|
return ConnectionInfo(
|
||||||
|
ssid = group.networkName ?: savedSsid ?: "",
|
||||||
|
password = group.passphrase ?: savedPassword ?: "",
|
||||||
|
ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP
|
||||||
|
connectedPeers = group.clientList?.size ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the P2P framework once. Every retry reuses this channel — calling
|
||||||
|
* initialize() per attempt registers a fresh binder with WifiP2pService that is
|
||||||
|
* never reclaimed until the process dies.
|
||||||
|
*/
|
||||||
|
private fun startWifiP2pFramework() {
|
||||||
|
Log.d(TAG, "Initialising P2P channel")
|
||||||
|
|
||||||
|
val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
|
||||||
|
|
||||||
|
if (newChannel == null) {
|
||||||
|
// The service is unobtainable; retrying will not change that.
|
||||||
|
Log.e(TAG, "Failed to initialize P2P channel")
|
||||||
|
failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel = newChannel
|
||||||
|
createGroupWhenP2pAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the framework for the current P2P state before the first attempt.
|
||||||
|
*
|
||||||
|
* When P2P is disabled the state machine answers every createGroup with BUSY —
|
||||||
|
* the same code a genuinely transient collision returns — so without this check
|
||||||
|
* a permanent failure is indistinguishable from a retryable one.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun createGroupWhenP2pAvailable() {
|
||||||
|
val ch = channel ?: return
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
clearStaleGroupThenCreate(ch, attempt = 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
wifiP2pManager?.requestP2pState(ch) { state ->
|
||||||
|
if (channel !== ch) return@requestP2pState
|
||||||
|
lastP2pState = state
|
||||||
|
clearStaleGroupThenCreate(ch, attempt = 1)
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e)
|
||||||
|
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A P2P group survives the process that created it, so a previous session killed
|
||||||
|
* while hosting leaves an orphan behind. The framework then rejects createGroup
|
||||||
|
* with BUSY for as long as that group exists, which no retry can clear.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) {
|
||||||
|
try {
|
||||||
|
wifiP2pManager?.requestGroupInfo(ch) { existingGroup ->
|
||||||
|
if (channel !== ch) return@requestGroupInfo
|
||||||
|
|
||||||
|
val action = HotspotStartupPolicy.startAction(
|
||||||
|
p2pState = lastP2pState,
|
||||||
|
existingGroupName = existingGroup?.networkName,
|
||||||
|
ownedGroupName = ownedGroupName,
|
||||||
|
confirmedGroupName = confirmedReplacementName
|
||||||
|
)
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
is HotspotStartupPolicy.StartAction.Fail -> {
|
||||||
|
Log.w(TAG, "Not attempting group creation: ${action.message}")
|
||||||
|
failStartup(action.message)
|
||||||
|
}
|
||||||
|
HotspotStartupPolicy.StartAction.ConfirmReplaceExisting -> {
|
||||||
|
val name = existingGroup?.networkName
|
||||||
|
if (name == null) {
|
||||||
|
// Unreachable while the policy requires a name, but there
|
||||||
|
// is nothing safe to bind consent to without one.
|
||||||
|
failStartup(HotspotStartupPolicy.P2P_BUSY_MESSAGE)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Existing group '$name' is not ours; asking the user")
|
||||||
|
reportExistingGroupConflict(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HotspotStartupPolicy.StartAction.Create ->
|
||||||
|
createGroup(attempt, oldGroupCleared = true)
|
||||||
|
HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> {
|
||||||
|
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
|
||||||
|
removeStaleGroup(ch, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
|
||||||
|
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeStaleGroup(ch: Channel, attempt: Int) {
|
||||||
|
try {
|
||||||
|
wifiP2pManager?.removeGroup(ch, object : ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
if (channel !== ch) return
|
||||||
|
Log.d(TAG, "Stale group removed")
|
||||||
|
createGroup(attempt, oldGroupCleared = true)
|
||||||
|
}
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
if (channel !== ch) return
|
||||||
|
// Creation may still succeed, and a BUSY reply here backs off as usual.
|
||||||
|
Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway")
|
||||||
|
createGroup(attempt, oldGroupCleared = false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while removing the existing group", e)
|
||||||
|
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Wi-Fi P2P group.
|
||||||
|
*
|
||||||
|
* @param oldGroupCleared false when a previous group may still exist (its removal
|
||||||
|
* just failed). The ownership marker keeps the OLD group's name in that case:
|
||||||
|
* overwriting it early would make a BUSY retry classify our own stale group as
|
||||||
|
* foreign and raise a spurious consent dialog. On success the group-info poll
|
||||||
|
* records the authoritative name anyway.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun createGroup(attempt: Int, oldGroupCleared: Boolean) {
|
||||||
|
val ch = channel ?: return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
// Record before the call: if the process dies between creation and the
|
||||||
|
// first group info, the next run still knows this orphan is ours.
|
||||||
|
if (oldGroupCleared) {
|
||||||
|
ownedGroupName = savedSsid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 10+: Custom SSID and password
|
||||||
|
val config = WifiP2pConfig.Builder()
|
||||||
|
.setNetworkName(savedSsid!!)
|
||||||
|
.setPassphrase(savedPassword!!)
|
||||||
|
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
|
||||||
|
.build()
|
||||||
|
|
||||||
|
wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch))
|
||||||
|
} else {
|
||||||
|
// Android 9 and below: System-generated SSID/password
|
||||||
|
wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
|
||||||
|
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
|
||||||
|
override fun onSuccess() {
|
||||||
|
if (channel !== requestChannel) {
|
||||||
|
// Ours by construction: this listener only observes our own createGroup.
|
||||||
|
Log.w(TAG, "Removing group created after hotspot was stopped")
|
||||||
|
try {
|
||||||
|
wifiP2pManager?.removeGroup(requestChannel, null)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// The orphan stays; the next start recognises it via ownedGroupName.
|
||||||
|
Log.e(TAG, "Could not remove the late group; permission was revoked", e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.d(TAG, "P2P group created successfully")
|
||||||
|
createdGroup = true
|
||||||
|
isStarting = false
|
||||||
|
// Don't call onHotspotStarted() yet - wait for group info
|
||||||
|
startGroupInfoPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(reason: Int) {
|
||||||
|
if (channel != null) {
|
||||||
|
handleGroupCreationFailure(reason, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle group creation failure, backing off only for genuinely transient causes.
|
||||||
|
*/
|
||||||
|
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
|
||||||
|
val reasonStr = when (reason) {
|
||||||
|
ERROR -> "ERROR"
|
||||||
|
P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
|
||||||
|
BUSY -> "BUSY"
|
||||||
|
else -> "UNKNOWN($reason)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Failed to create group: $reasonStr " +
|
||||||
|
"(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)"
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) {
|
||||||
|
is HotspotStartupPolicy.Decision.Retry -> {
|
||||||
|
Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms")
|
||||||
|
handler.postDelayed({
|
||||||
|
// Re-check for a stale group each round: BUSY is also how the
|
||||||
|
// framework reports "a group already exists".
|
||||||
|
channel?.let { clearStaleGroupThenCreate(it, attempt + 1) }
|
||||||
|
}, decision.delayMillis)
|
||||||
|
}
|
||||||
|
is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal startup failure: release all resources (locks, receiver, handler
|
||||||
|
* callbacks) before notifying the callback, so a failed attempt doesn't leak
|
||||||
|
* and block subsequent attempts.
|
||||||
|
*/
|
||||||
|
private fun failStartup(message: String) {
|
||||||
|
val cb = callback
|
||||||
|
stopHotspot()
|
||||||
|
cb?.onError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group belonging to another app is up. Stop cleanly — with [createdGroup]
|
||||||
|
* false the stop path leaves that group untouched — and let the UI ask whether
|
||||||
|
* starting the hotspot may disconnect it.
|
||||||
|
*/
|
||||||
|
private fun reportExistingGroupConflict(groupName: String) {
|
||||||
|
val cb = callback
|
||||||
|
stopHotspot()
|
||||||
|
cb?.onExistingGroupConflict(groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for group info to track connected clients.
|
||||||
|
*/
|
||||||
|
private fun startGroupInfoPolling() {
|
||||||
|
requestGroupInfo()
|
||||||
|
|
||||||
|
// Keep polling even while the group info is still null — the first
|
||||||
|
// requestGroupInfo() after createGroup() can legitimately return null
|
||||||
|
// while the group is forming. Give up only after a timeout.
|
||||||
|
var elapsedMillis = 0L
|
||||||
|
handler.postDelayed(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (channel == null) return
|
||||||
|
|
||||||
|
elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS
|
||||||
|
if (currentGroup == null && !hasNotifiedStarted &&
|
||||||
|
elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS
|
||||||
|
) {
|
||||||
|
Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms")
|
||||||
|
failStartup("Hotspot failed to start. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestGroupInfo()
|
||||||
|
handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
|
||||||
|
}
|
||||||
|
}, GROUP_INFO_POLL_INTERVAL_MILLIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request current group information.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun requestGroupInfo() {
|
||||||
|
val ch = channel ?: return
|
||||||
|
|
||||||
|
try {
|
||||||
|
wifiP2pManager?.requestGroupInfo(ch) { group ->
|
||||||
|
// A reply arriving after the hotspot stopped must not revive any
|
||||||
|
// state the stop just cleared.
|
||||||
|
if (channel !== ch) return@requestGroupInfo
|
||||||
|
|
||||||
|
reconcileGroupOwnership(group)
|
||||||
|
|
||||||
|
if (group == null) {
|
||||||
|
Log.w(TAG, "requestGroupInfo returned null group")
|
||||||
|
return@requestGroupInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOurHostedGroup(group)) {
|
||||||
|
// Someone else's group is on the radio. Reading anything from it
|
||||||
|
// — its name, its credentials, its client count — would report
|
||||||
|
// another app's session as our hotspot, and recording its name
|
||||||
|
// would let the next start remove it without asking.
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Observed group '${group.networkName}' is not the one we created"
|
||||||
|
)
|
||||||
|
return@requestGroupInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGroup = group
|
||||||
|
|
||||||
|
// Update saved credentials if using system-generated ones
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
savedSsid = group.networkName
|
||||||
|
savedPassword = group.passphrase
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative name straight from the framework, for the group we
|
||||||
|
// just confirmed is ours.
|
||||||
|
group.networkName?.let {
|
||||||
|
hostedGroupName = it
|
||||||
|
ownedGroupName = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify callback on FIRST successful group info retrieval
|
||||||
|
if (!hasNotifiedStarted) {
|
||||||
|
hasNotifiedStarted = true
|
||||||
|
Log.d(TAG, "Group info received, notifying callback")
|
||||||
|
callback?.onHotspotStarted()
|
||||||
|
} else {
|
||||||
|
// Subsequent updates
|
||||||
|
callback?.onConnectionInfoUpdated(getConnectionInfo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
|
||||||
|
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this snapshot the group this session created?
|
||||||
|
*
|
||||||
|
* `isGroupOwner` cannot answer that on its own: it reports that *this device*
|
||||||
|
* hosts the group, which is equally true of an autonomous group another app
|
||||||
|
* created here. Above Q we chose the network name, so it identifies our group
|
||||||
|
* exactly. Below Q the framework names it, and the first snapshot after our own
|
||||||
|
* createGroup succeeded is the only evidence available — after that the name is
|
||||||
|
* fixed, and a group answering to a different one is not ours.
|
||||||
|
*/
|
||||||
|
private fun isOurHostedGroup(group: WifiP2pGroup): Boolean {
|
||||||
|
if (!group.isGroupOwner) return false
|
||||||
|
val name = group.networkName ?: return false
|
||||||
|
|
||||||
|
hostedGroupName?.let { return name == it }
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
name == savedSsid
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep [createdGroup] honest about what is actually on the framework.
|
||||||
|
*
|
||||||
|
* Our group can disappear without us — Wi-Fi toggled, another app issuing its own
|
||||||
|
* device-scoped removeGroup(), a driver reset — and another app can then create
|
||||||
|
* one in its place. Believing the group present is still ours would make stop
|
||||||
|
* remove that replacement, the exact disruption consent exists to prevent.
|
||||||
|
*
|
||||||
|
* Reconciled only once the framework has named our group: before that a null
|
||||||
|
* snapshot means the group is still forming, not that it is gone. Losing the flag
|
||||||
|
* to a transient null is safe in a way that keeping it is not — the group we
|
||||||
|
* created is then left behind, and the next start recognises it by name and
|
||||||
|
* removes it silently.
|
||||||
|
*/
|
||||||
|
private fun reconcileGroupOwnership(group: WifiP2pGroup?) {
|
||||||
|
val expectedName = hostedGroupName ?: if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
|
) {
|
||||||
|
savedSsid
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
// A null snapshot is normal before the configured group appears. A non-null
|
||||||
|
// group with a different name is positive evidence that ours was replaced.
|
||||||
|
if (hostedGroupName == null && group == null) return
|
||||||
|
|
||||||
|
val stillOurs = group != null && isOurHostedGroup(group)
|
||||||
|
if (createdGroup && !stillOurs) {
|
||||||
|
Log.w(TAG, "Group '$expectedName' is no longer ours; leaving what is present alone")
|
||||||
|
}
|
||||||
|
createdGroup = stillOurs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire WakeLock and WifiLock to keep hotspot active.
|
||||||
|
*/
|
||||||
|
private fun acquireLocks() {
|
||||||
|
try {
|
||||||
|
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"BitChat:HotspotWakeLock"
|
||||||
|
)
|
||||||
|
wakeLock?.acquire(30 * 60 * 1000L)
|
||||||
|
|
||||||
|
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
|
||||||
|
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||||
|
} else {
|
||||||
|
android.net.wifi.WifiManager.WIFI_MODE_FULL
|
||||||
|
}
|
||||||
|
wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock")
|
||||||
|
wifiLock?.acquire()
|
||||||
|
|
||||||
|
Log.d(TAG, "Acquired WakeLock and WifiLock")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error acquiring locks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release WakeLock and WifiLock.
|
||||||
|
*/
|
||||||
|
private fun releaseLocks() {
|
||||||
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
|
||||||
|
wifiLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wifiLock = null
|
||||||
|
|
||||||
|
Log.d(TAG, "Released WakeLock and WifiLock")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error releasing locks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the IP address of the P2P access point.
|
||||||
|
* Looks for network interface starting with "p2p".
|
||||||
|
*/
|
||||||
|
private fun getAccessPointAddress(): String? {
|
||||||
|
try {
|
||||||
|
val interfaces = NetworkInterface.getNetworkInterfaces()
|
||||||
|
while (interfaces.hasMoreElements()) {
|
||||||
|
val iface = interfaces.nextElement()
|
||||||
|
if (iface.name.startsWith("p2p")) {
|
||||||
|
val addresses = iface.interfaceAddresses
|
||||||
|
for (addr in addresses) {
|
||||||
|
val address = addr.address
|
||||||
|
// IPv4 only (4 bytes)
|
||||||
|
if (address.address.size == 4) {
|
||||||
|
return address.hostAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting access point address", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random SSID.
|
||||||
|
* Format: DIRECT-BC-XXXXXXXX
|
||||||
|
*/
|
||||||
|
private fun generateSsid(): String {
|
||||||
|
val suffix = (1..SSID_SUFFIX_LENGTH)
|
||||||
|
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
|
||||||
|
.joinToString("")
|
||||||
|
return "${HotspotStartupPolicy.SSID_PREFIX}$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random password.
|
||||||
|
* 16 characters, excluding confusing characters.
|
||||||
|
*/
|
||||||
|
private fun generatePassword(): String {
|
||||||
|
return (1..PASSWORD_LENGTH)
|
||||||
|
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
|
||||||
|
.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection information for the hotspot.
|
||||||
|
*/
|
||||||
|
data class ConnectionInfo(
|
||||||
|
val ssid: String,
|
||||||
|
val password: String,
|
||||||
|
val ipAddress: String,
|
||||||
|
val connectedPeers: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback interface for hotspot events.
|
||||||
|
*/
|
||||||
|
interface HotspotCallback {
|
||||||
|
fun onHotspotStarted()
|
||||||
|
fun onConnectionInfoUpdated(info: ConnectionInfo?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Wi-Fi Direct group belonging to another app is active and the caller has
|
||||||
|
* not confirmed replacing it. Ask the user, then retry with this name as
|
||||||
|
* `confirmedReplacementName` if they accept. Nothing was disturbed.
|
||||||
|
*/
|
||||||
|
fun onExistingGroupConflict(groupName: String)
|
||||||
|
|
||||||
|
fun onError(message: String)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package com.bitchat.android.hotspot
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
internal object HotspotPermissions {
|
||||||
|
const val ANDROID_17_API_LEVEL = 37
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
fun requiredForSdk(sdkInt: Int = Build.VERSION.SDK_INT): List<String> {
|
||||||
|
return when {
|
||||||
|
sdkInt >= ANDROID_17_API_LEVEL -> listOf(
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||||
|
Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||||
|
)
|
||||||
|
sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf(
|
||||||
|
Manifest.permission.NEARBY_WIFI_DEVICES
|
||||||
|
)
|
||||||
|
sdkInt >= Build.VERSION_CODES.Q -> listOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun missingFrom(context: Context): List<String> {
|
||||||
|
return requiredForSdk().filter { permission ->
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||