mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
570
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddd5f3cfd | ||
|
|
31f4d4aafa | ||
|
|
e06dc933b8 | ||
|
|
1418be999d | ||
|
|
df06a49949 | ||
|
|
b6dcc74383 | ||
|
|
fead8381bc | ||
|
|
2ab9f8ace6 | ||
|
|
81effcb349 | ||
|
|
3dd1f8151a | ||
|
|
272a7121a9 | ||
|
|
52a7850c1d | ||
|
|
b297ad8564 | ||
|
|
a3810353f7 | ||
|
|
d4dddb1f38 | ||
|
|
21560a1f3a | ||
|
|
bdfb6bb073 | ||
|
|
aac0590017 | ||
|
|
afba33dd83 | ||
|
|
5de5dee52f | ||
|
|
ba3e253569 | ||
|
|
4488af0ab7 | ||
|
|
aae8ad62bd | ||
|
|
9606a33f15 | ||
|
|
f626d70538 | ||
|
|
a91ef44686 | ||
|
|
e3bef4e74b | ||
|
|
358bf5e244 | ||
|
|
fba2d3a59d | ||
|
|
0abba4ece2 | ||
|
|
e9b0694b67 | ||
|
|
49bbf1bc85 | ||
|
|
f5b6a5c605 | ||
|
|
fccc6cedd5 | ||
|
|
a03766448e | ||
|
|
f0c9d318ff | ||
|
|
5c5cc6f28e | ||
|
|
246b96bfb4 | ||
|
|
352e020b54 | ||
|
|
72f5690ea6 | ||
|
|
1ed1199574 | ||
|
|
fd4a43de96 | ||
|
|
e406fd4cc1 | ||
|
|
1e555cc712 | ||
|
|
227a6b2a7b | ||
|
|
5f661d71a6 | ||
|
|
2804543b9d | ||
|
|
380397321c | ||
|
|
d9c8902d34 | ||
|
|
a1b9abd4cb | ||
|
|
5d30844fa8 | ||
|
|
af1b1b4249 | ||
|
|
2cce9ae56c | ||
|
|
4bab7c61a9 | ||
|
|
81e2bc1dba | ||
|
|
df2783d8a1 | ||
|
|
20a6953b54 | ||
|
|
fdd4f69247 | ||
|
|
5ee777b196 | ||
|
|
dd827af3cd | ||
|
|
948205667b | ||
|
|
dbbfa75429 | ||
|
|
78790f73df | ||
|
|
2950d8b3f9 | ||
|
|
4d3a9bc165 | ||
|
|
7d57845502 | ||
|
|
83ef111599 | ||
|
|
0e6848d886 | ||
|
|
1eeeb63a76 | ||
|
|
a4ba302223 | ||
|
|
8c9094d85a | ||
|
|
4529b107dd | ||
|
|
17663bc759 | ||
|
|
278257998b | ||
|
|
9d41979499 | ||
|
|
985b73754e | ||
|
|
e4e4190523 | ||
|
|
ebfb034fdd | ||
|
|
8a81d354e6 | ||
|
|
ac0c4693dc | ||
|
|
4fd7a41c53 | ||
|
|
cc1caf47f0 | ||
|
|
8b9f953818 | ||
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd | ||
|
|
3f310016b1 | ||
|
|
d399a6d908 | ||
|
|
fb104cbe61 | ||
|
|
97cbca7a6e | ||
|
|
02278dab17 | ||
|
|
2240bd7ae5 | ||
|
|
bc4096cb57 | ||
|
|
5a0ba1b0bd | ||
|
|
d04abdf80c | ||
|
|
0609352cc9 | ||
|
|
abde0b7d6a | ||
|
|
b952aff38d | ||
|
|
c8f5ceda34 | ||
|
|
e6dbd2d660 | ||
|
|
228d7d1f0c | ||
|
|
67fead3589 | ||
|
|
d0aab583d2 | ||
|
|
367c8d28ff | ||
|
|
b04e1fe628 | ||
|
|
ea3ef1ff3b | ||
|
|
ff8b9cf6ed | ||
|
|
96bc3f5df7 | ||
|
|
1891a6d73a | ||
|
|
698415237d | ||
|
|
7ef672a11f | ||
|
|
f67e8aa7d1 | ||
|
|
bcb4bdff08 | ||
|
|
70d9432b2b | ||
|
|
9d06839a1c | ||
|
|
97f294e5f7 | ||
|
|
a40599c1dc | ||
|
|
175aef9e2f | ||
|
|
36ae35918a | ||
|
|
60ad1943b6 | ||
|
|
5e727e907d | ||
|
|
5360f54ea8 | ||
|
|
486efe6e65 | ||
|
|
c2ff1f111e | ||
|
|
26b72726a7 | ||
|
|
f35fe968c6 | ||
|
|
498f040a57 | ||
|
|
59b3106c3b | ||
|
|
89e8b83be4 | ||
|
|
f0531796dd | ||
|
|
a701fdfba7 | ||
|
|
118eaa8d08 | ||
|
|
016914ec05 | ||
|
|
0aadec0b75 | ||
|
|
e6cf507937 | ||
|
|
f27d84fec3 | ||
|
|
7632ba9e0a | ||
|
|
fd20ef0500 | ||
|
|
250d0ab154 | ||
|
|
79886e0bfb | ||
|
|
710d443074 | ||
|
|
86b685f28b | ||
|
|
bba9155573 | ||
|
|
c19b0b35b6 | ||
|
|
ec087e4b07 | ||
|
|
e7c280cb52 | ||
|
|
8a3a6cade5 | ||
|
|
77486fcd48 | ||
|
|
f4ec4630e4 | ||
|
|
384d81be18 | ||
|
|
07b3b95270 | ||
|
|
5b187c6d5b | ||
|
|
5ee094da3c | ||
|
|
77ea31216f | ||
|
|
74e48e79eb | ||
|
|
f141ce3f2a | ||
|
|
14da81d231 | ||
|
|
789c23d24c | ||
|
|
4ba59636c0 | ||
|
|
e5821de4dd | ||
|
|
59b4c404aa | ||
|
|
0d566a5aec | ||
|
|
35c06d9982 | ||
|
|
3ad732ff09 | ||
|
|
32ff7e5bdd | ||
|
|
fd6a9667e2 | ||
|
|
c5d9bed5f0 | ||
|
|
90af17bf7d | ||
|
|
6f90687b72 | ||
|
|
f17b6ca99f | ||
|
|
397b32b425 | ||
|
|
4b80ac7336 | ||
|
|
5d5e6670d4 | ||
|
|
da64406638 | ||
|
|
77b8992355 | ||
|
|
f3b540987d | ||
|
|
1a0bfbb3f4 | ||
|
|
02fd83440b | ||
|
|
7f82119001 | ||
|
|
8229163f70 | ||
|
|
6d313c077d | ||
|
|
60970a2a0c | ||
|
|
c28502fc8a | ||
|
|
2f3cae2b2c | ||
|
|
566a6cd120 | ||
|
|
7d08a44a19 | ||
|
|
dae12e588f | ||
|
|
6d5c5d29cc | ||
|
|
bac970fa61 | ||
|
|
6e8439e2f4 | ||
|
|
df0fcede6f | ||
|
|
d4a355c356 | ||
|
|
200e32f67a | ||
|
|
c0060ce5ca | ||
|
|
34c23751b3 | ||
|
|
04c23ecf63 | ||
|
|
b1aee5ee83 | ||
|
|
2a8416d146 | ||
|
|
1f7bc7cfd8 | ||
|
|
46fcf5a4cb | ||
|
|
a4e7820e6f | ||
|
|
e6e66c3831 | ||
|
|
71ba531025 | ||
|
|
6825abaa41 | ||
|
|
a4e1a3ba2e | ||
|
|
1cd8ae5ace | ||
|
|
c1601c105c | ||
|
|
b8f342f096 | ||
|
|
a6f292f737 | ||
|
|
a37b9b0213 | ||
|
|
65a74e9bc1 | ||
|
|
609423b27d | ||
|
|
067eb2e9e0 | ||
|
|
29df90628f | ||
|
|
70d9c047dc | ||
|
|
7f2b6976ce | ||
|
|
37e2593d8a | ||
|
|
30163a3f69 | ||
|
|
ed6c939284 | ||
|
|
a51b24f284 | ||
|
|
09c0692205 | ||
|
|
4174072dbf | ||
|
|
aeaf92d7e6 | ||
|
|
a650405c2d | ||
|
|
7883dcbafb | ||
|
|
300e6b8c49 | ||
|
|
33b6fbd0e0 | ||
|
|
f2bf39bda1 | ||
|
|
87d366bcb7 | ||
|
|
df5732569f | ||
|
|
56e01cfde9 | ||
|
|
ed37e142ad | ||
|
|
cb915d6263 | ||
|
|
41245d928c | ||
|
|
ccd95a0981 | ||
|
|
2d38db5cba | ||
|
|
175f8e36b5 | ||
|
|
a102b03261 | ||
|
|
57fc4d8e7b | ||
|
|
f67336d4fe | ||
|
|
a511c57b7d | ||
|
|
66e3ab1670 | ||
|
|
7e73ac80e9 | ||
|
|
1ae9ae587e | ||
|
|
4631aa8bb2 | ||
|
|
33c8fe76a6 | ||
|
|
e356344ba6 | ||
|
|
e014ad8045 | ||
|
|
9d446938bd | ||
|
|
fea1092a95 | ||
|
|
adfedc1cce | ||
|
|
c609b03148 | ||
|
|
927a198d8e | ||
|
|
1f5f406b33 | ||
|
|
4a475419e8 | ||
|
|
7c37854076 | ||
|
|
30d7efeec2 | ||
|
|
942e03af36 | ||
|
|
24a84a49f1 | ||
|
|
e3693628c2 | ||
|
|
94156b6200 | ||
|
|
5ae15d828d | ||
|
|
360067b07e | ||
|
|
e91243e577 | ||
|
|
19d61ca5cb | ||
|
|
7b403defab | ||
|
|
ceb4108306 | ||
|
|
fe4ac306cf | ||
|
|
618229b96b | ||
|
|
1931e60b07 | ||
|
|
7bdc9ca212 | ||
|
|
04ea3d0cd8 | ||
|
|
fef6c97d2d | ||
|
|
dc2e318374 | ||
|
|
71638c13ab | ||
|
|
bd5c6bf858 | ||
|
|
70e5b99e1d | ||
|
|
f65c6e4a29 | ||
|
|
c860ecc430 | ||
|
|
a8593eecd3 | ||
|
|
94e28fb0b5 | ||
|
|
4808e04146 | ||
|
|
aebf43c355 | ||
|
|
c1cd99f701 | ||
|
|
3fa01bb510 | ||
|
|
e84f8762b4 | ||
|
|
ac21261d4d | ||
|
|
c19ee88ae6 | ||
|
|
f751cdb37e | ||
|
|
28cd3b1340 | ||
|
|
3e2852e622 | ||
|
|
b919d8d19b | ||
|
|
9d6ec2b6bb | ||
|
|
9a85b23d91 | ||
|
|
217d9be177 | ||
|
|
53447f3fb9 | ||
|
|
db759bac7c | ||
|
|
6837db5b84 | ||
|
|
83b24cced2 | ||
|
|
d913e72c5c | ||
|
|
5c16c08dee | ||
|
|
20e22c0910 | ||
|
|
fa3e56b0f3 | ||
|
|
f25718ab2c | ||
|
|
6e9b275936 | ||
|
|
c6c7cd7858 | ||
|
|
4d7027a9b8 | ||
|
|
992cb5e695 | ||
|
|
039d20d7aa | ||
|
|
52433460be | ||
|
|
95bb225e64 | ||
|
|
ac477113cf | ||
|
|
5e21941765 | ||
|
|
e5032ee35a | ||
|
|
1ceac349ab | ||
|
|
2db01b410b | ||
|
|
193a1030fb | ||
|
|
12e35631d6 | ||
|
|
67474a30da | ||
|
|
44cd0eec2a | ||
|
|
a79f6a0535 | ||
|
|
8675d73a98 | ||
|
|
3423786f06 | ||
|
|
00083707c6 | ||
|
|
0cdd2a4f7a | ||
|
|
8e3b235c81 | ||
|
|
879ec1b69c | ||
|
|
c833fd2c92 | ||
|
|
d7d9d53ff3 | ||
|
|
cba239f411 | ||
|
|
853e877a59 | ||
|
|
de89c51457 | ||
|
|
7f8daddfe0 | ||
|
|
a76e43ac04 | ||
|
|
fd0dc49396 | ||
|
|
a5f5feacae | ||
|
|
70a3114876 | ||
|
|
557ee9b3ed | ||
|
|
58bd0f91f5 | ||
|
|
b2f8586dc9 | ||
|
|
62797e8ea2 | ||
|
|
c614e7bd4f | ||
|
|
62e39ce3d2 | ||
|
|
7df74e3b44 | ||
|
|
06e5954ef5 | ||
|
|
450a187b65 | ||
|
|
da6a6f6ff2 | ||
|
|
d37c8aa3a5 | ||
|
|
5cd97805b4 | ||
|
|
e35bce4d19 | ||
|
|
1a196689db | ||
|
|
8ada7b071d | ||
|
|
a92e364ca6 | ||
|
|
6326f4a5b9 | ||
|
|
abb189f43c | ||
|
|
e80bf1db6c | ||
|
|
cd6488a713 | ||
|
|
e13331dfcf | ||
|
|
dcf2ade9ec | ||
|
|
40f0aba462 | ||
|
|
c760d5758d | ||
|
|
b609c1aced | ||
|
|
b3b55f1ca3 | ||
|
|
073de3cf35 | ||
|
|
8c278f2a3c | ||
|
|
c103c6adc9 | ||
|
|
4db2edd29a | ||
|
|
eefe740b6f | ||
|
|
a47addc8bc | ||
|
|
9cc5a82adf | ||
|
|
bec24bfd6f | ||
|
|
e6eb0d818f | ||
|
|
25e3e7d792 | ||
|
|
ebcb997bdd | ||
|
|
76c55bf3be | ||
|
|
fc9bcafae9 | ||
|
|
c4e58917da | ||
|
|
3730a78e7a | ||
|
|
222a6c43c9 | ||
|
|
b1fe8490d3 | ||
|
|
a0fe20e296 | ||
|
|
e2d127dae9 | ||
|
|
b5c48be08e | ||
|
|
90f0c89b79 | ||
|
|
8dd165e1c8 | ||
|
|
119139cbc5 | ||
|
|
5159e4c21b | ||
|
|
1c94354831 | ||
|
|
75d2bf0169 | ||
|
|
7e73777469 | ||
|
|
3d07c284b9 | ||
|
|
f6b1c69edc | ||
|
|
f421deca49 | ||
|
|
e19608f567 | ||
|
|
06f4115808 | ||
|
|
2ca8f686c8 | ||
|
|
679250f7da | ||
|
|
8df2d78070 | ||
|
|
63692595a8 | ||
|
|
576c6db8d0 | ||
|
|
524cac8d30 | ||
|
|
8939f788ad | ||
|
|
9ad3f3c180 | ||
|
|
5b59ddab0f | ||
|
|
ab24c48024 | ||
|
|
a6482c7d76 | ||
|
|
99aa44b448 | ||
|
|
a801f2d701 | ||
|
|
8405406d22 | ||
|
|
9785b5ea7f | ||
|
|
fd7f9f8ab3 | ||
|
|
46bb8c98de | ||
|
|
b3ead17b12 | ||
|
|
a794d59276 | ||
|
|
584c5c70d0 | ||
|
|
5a55796518 | ||
|
|
bdfdd93735 | ||
|
|
e9a937e4f6 | ||
|
|
0a8bae46a9 | ||
|
|
7efcafbc71 | ||
|
|
6b3f431e9e | ||
|
|
6014004c0f | ||
|
|
ebbc5394a7 | ||
|
|
e36868a2fc | ||
|
|
1c6fb8ed31 | ||
|
|
e780fd17d2 | ||
|
|
86d3f3bc61 | ||
|
|
ba554c8787 | ||
|
|
968829072a | ||
|
|
0d1f331551 | ||
|
|
a8bb42d40b | ||
|
|
9a3fb11ef7 | ||
|
|
12d2c4dd06 | ||
|
|
cfed733fa7 | ||
|
|
1af8743533 | ||
|
|
2f3f1f3f70 | ||
|
|
6223524a1e | ||
|
|
ebfffb1536 | ||
|
|
5aa36b148b | ||
|
|
29391feaf8 | ||
|
|
4e279ca44d | ||
|
|
9ff8d9e04e | ||
|
|
10d5a823fc | ||
|
|
54afadb136 | ||
|
|
e4db9387ca | ||
|
|
4834eb276a | ||
|
|
5860bbffb6 | ||
|
|
da53d68190 | ||
|
|
b397bf190c | ||
|
|
15d664c361 | ||
|
|
d2f24d8b95 | ||
|
|
b5436659b6 | ||
|
|
1cfc4611cb | ||
|
|
bda0a20743 | ||
|
|
09bad90c0c | ||
|
|
34856607f8 | ||
|
|
5c2b66fe27 | ||
|
|
c9fe9a43e2 | ||
|
|
395dbbb624 | ||
|
|
fdc6486102 | ||
|
|
6c379451c0 | ||
|
|
221f3ccbed | ||
|
|
12f0662146 | ||
|
|
7cc81bd554 | ||
|
|
618fddb698 | ||
|
|
0f74cb9b39 | ||
|
|
743f5aeb89 | ||
|
|
9260a24e0d | ||
|
|
49fc0f018a | ||
|
|
ad7089ea7c | ||
|
|
e74b83581a | ||
|
|
a3fb26f967 | ||
|
|
2b4161b9e7 | ||
|
|
d38f2ea3e9 | ||
|
|
ba4445f19d | ||
|
|
33cab939f0 | ||
|
|
69ce7e18f5 | ||
|
|
e2014a26fc | ||
|
|
1e806ea416 | ||
|
|
6cf85935f9 | ||
|
|
25b838da5c | ||
|
|
03676346e9 | ||
|
|
f2e6d8978c | ||
|
|
fbbbf751c5 | ||
|
|
132b3996fc | ||
|
|
7b41f83a53 | ||
|
|
b6027186c0 | ||
|
|
d2d126e9d9 | ||
|
|
89632d864a | ||
|
|
2cd209ee03 | ||
|
|
b96d6ef4db | ||
|
|
d6af8e21cc | ||
|
|
ae2d1c6868 | ||
|
|
98cf4ff3e7 | ||
|
|
679c9cd070 | ||
|
|
5b281303d9 | ||
|
|
cb28bbfaf3 | ||
|
|
cd190389cd | ||
|
|
0af469af07 | ||
|
|
1689153070 | ||
|
|
520458b107 | ||
|
|
be8f4919c8 | ||
|
|
deb012600d | ||
|
|
15371e01ef | ||
|
|
0e3ee655ef | ||
|
|
da5af8e4b1 | ||
|
|
6c848eaa55 | ||
|
|
15054d836d | ||
|
|
0ac9bba482 | ||
|
|
247bc13e0a | ||
|
|
33126be817 | ||
|
|
b02b31043b | ||
|
|
6786b5aa8a | ||
|
|
8577e7bae8 | ||
|
|
df88399e7a | ||
|
|
16392d5721 | ||
|
|
2edf5411ce | ||
|
|
89e46d3858 | ||
|
|
da45eb5dda | ||
|
|
a1868f495d | ||
|
|
ceb5034c3b | ||
|
|
02ff7089c0 | ||
|
|
2fa33b13a3 | ||
|
|
96a654638a | ||
|
|
b4aa7b0815 | ||
|
|
df5389c27e | ||
|
|
0611ec0e08 | ||
|
|
c325112b44 | ||
|
|
65f9e1d9db | ||
|
|
c97c8f4852 | ||
|
|
6b2073eff2 | ||
|
|
2b08d9a4d0 | ||
|
|
977f135c50 | ||
|
|
3c777d377e | ||
|
|
b98994f2f1 | ||
|
|
85860a53bf | ||
|
|
7a967ce648 | ||
|
|
4c7eac870d | ||
|
|
ff58e01551 | ||
|
|
004ea6cbcb | ||
|
|
a42b88aa78 | ||
|
|
43088cf8b8 | ||
|
|
9460584891 | ||
|
|
570118ca94 | ||
|
|
d9f0de2e05 | ||
|
|
3d5dcfb21d | ||
|
|
e0dc9ea737 | ||
|
|
b4d0904f55 | ||
|
|
2caee59713 | ||
|
|
6712ba78ba | ||
|
|
cba30aee47 | ||
|
|
c6cd8d68f7 | ||
|
|
d2fbf9d79f | ||
|
|
738a35dbe9 | ||
|
|
1278ec55c2 | ||
|
|
95ff34b373 | ||
|
|
1329429130 | ||
|
|
8c3c09dc03 | ||
|
|
10bf65bd09 | ||
|
|
b57b3458ab | ||
|
|
6aeee4816c |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 760 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 761 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
worktrees/
|
||||||
|
scheduled_tasks.lock
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# CAPod - Companion for AirPods
|
||||||
|
|
||||||
|
Android app that detects and monitors AirPods via Bluetooth LE. Displays battery levels, triggers popup notifications on case open, and provides home screen widgets.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
|
||||||
|
|
||||||
|
## Build Flavors
|
||||||
|
|
||||||
|
- **FOSS** (`foss`): Open-source, no Google Play dependencies
|
||||||
|
- **Google Play** (`gplay`): Includes billing client for IAP
|
||||||
|
|
||||||
|
Quick build check: `./gradlew assembleFossDebug`
|
||||||
|
|
||||||
|
## Key Locations
|
||||||
|
|
||||||
|
| Path | Contains |
|
||||||
|
|------|----------|
|
||||||
|
| `app/src/main/java/` | Main app source (Compose screens, services, receivers, monitor, bluetooth, models) |
|
||||||
|
| `app/src/foss/java/`, `app/src/gplay/java/` | Flavor-specific code (e.g. upgrade/billing) |
|
||||||
|
| `app/src/main/res/` | Layouts, drawables, strings |
|
||||||
|
| `app/src/test/`, `app/src/testFoss/`, `app/src/testGplay/` | Unit tests (shared + flavor-specific) |
|
||||||
|
| `app/build.gradle.kts` | App build config, dependencies, flavors |
|
||||||
|
| `app/src/debug/java/.../screenshots/` | Play Store screenshot content composables |
|
||||||
|
| `fastlane/` | Screenshot generation scripts, Play Store metadata |
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
- Use `assembleFossDebug` as the fastest build variant for iteration
|
||||||
|
- Follow existing patterns — the codebase uses MVVM + Hilt + Coroutines
|
||||||
|
- Always use string resources for user-facing text (see localization rules)
|
||||||
|
- Check `git log --oneline -20` for commit message style before committing
|
||||||
|
|
||||||
|
## Rules Reference
|
||||||
|
|
||||||
|
Detailed guidelines are in `.claude/rules/`:
|
||||||
|
|
||||||
|
- `architecture.md` — Module structure, key components, data flow, dependencies
|
||||||
|
- `build-commands.md` — Build, test, lint, and release commands
|
||||||
|
- `localization.md` — String resource naming conventions
|
||||||
|
- `commit-guidelines.md` — Commit message format and prefixes
|
||||||
|
- `pull-requests.md` — PR title and description conventions
|
||||||
|
- `agent-instructions.md` — Sub-agent delegation and critical thinking
|
||||||
|
- `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens
|
||||||
|
- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
description: Instructions for Claude Code sub-agents and task delegation
|
||||||
|
globs:
|
||||||
|
- "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Critical Thinking
|
||||||
|
|
||||||
|
- Do not blindly accept information at face value
|
||||||
|
- Verify assumptions against actual code before proceeding
|
||||||
|
- When encountering unexpected behavior, investigate root causes rather than applying workarounds
|
||||||
|
- If something seems wrong, it probably is — dig deeper
|
||||||
|
|
||||||
|
## Explore vs. Implement
|
||||||
|
|
||||||
|
- **Explore first**: Before making changes, understand the existing code structure and patterns
|
||||||
|
- **Read before writing**: Always read relevant files before modifying them
|
||||||
|
- **Follow existing patterns**: Match the code style and architecture already in use
|
||||||
|
- **Minimal changes**: Only change what's necessary to accomplish the task
|
||||||
|
|
||||||
|
## Sub-Agent Delegation
|
||||||
|
|
||||||
|
When using Task tool to spawn sub-agents:
|
||||||
|
|
||||||
|
- Provide complete context — sub-agents don't share your conversation history unless noted
|
||||||
|
- Be specific about what you need: research only, or research + implementation
|
||||||
|
- Use `Explore` agent type for codebase investigation
|
||||||
|
- Use `Bash` agent type for running builds and tests
|
||||||
|
- Parallelize independent sub-agent tasks for efficiency
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Don't create new files when editing existing ones would suffice
|
||||||
|
- Don't add features beyond what was requested
|
||||||
|
- Don't refactor surrounding code when fixing a bug
|
||||||
|
- Don't add comments or documentation to code you didn't change
|
||||||
|
- Don't guess at file paths — use Glob/Grep to find them
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
description: Architecture overview, module structure, key components, data flow, and dependencies
|
||||||
|
globs:
|
||||||
|
- "app/**/*.kt"
|
||||||
|
- "**/*.gradle.kts"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Single-Module Structure
|
||||||
|
|
||||||
|
One Gradle module: `app/`. Source sets:
|
||||||
|
|
||||||
|
- `main` — shared code (Compose UI, services, monitor, bluetooth, AAP protocol, widgets)
|
||||||
|
- `foss` / `gplay` — flavor-specific code (e.g. upgrade/billing implementations)
|
||||||
|
- `debug` — debug-only code including screenshot content composables
|
||||||
|
- `test` / `testFoss` / `testGplay` — unit tests
|
||||||
|
- `screenshotTest` — Compose Preview Screenshot tests for Play Store assets
|
||||||
|
|
||||||
|
A previous `app-common/` module was merged into `app/` (commit `be8f4919`).
|
||||||
|
|
||||||
|
## Core Patterns
|
||||||
|
|
||||||
|
- **MVVM**: ViewModels with LiveData/StateFlow for UI state management
|
||||||
|
- **Dependency Injection**: Hilt/Dagger for dependency management
|
||||||
|
- **Coroutines**: Kotlin coroutines for async operations
|
||||||
|
- **Repository Pattern**: Data layer abstraction for monitoring and settings
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### Device Monitoring
|
||||||
|
|
||||||
|
`monitor/core/` is split into two data-source siblings that `DeviceMonitor` merges:
|
||||||
|
|
||||||
|
- `monitor/core/ble/BlePodMonitor` — passive BLE scanning; reads Apple advertisement beacons (battery, case state, in-ear, etc.). Works for any pod in range; no pairing required
|
||||||
|
- `monitor/core/aap/` — AAP connection lifecycle layer on top of `AapConnectionManager`:
|
||||||
|
- `AapLifecycleManager` — starts/stops the AAP subsystem
|
||||||
|
- `AapAutoConnect` — auto-opens AAP sessions for bonded/known devices
|
||||||
|
- `AapKeyPersister`, `AapLearnedSettingsPersister` — persist session keys and learned pod settings across app restarts
|
||||||
|
- `StemConfigSender`, `StemPressReaction`, `AncGestureResolver` — push config and react to stem/HID events
|
||||||
|
- `monitor/core/cache/DeviceStateCache` — persisted last-known state so profiles still show data when a device is out of range
|
||||||
|
- `DeviceMonitor` — singleton that `combine`s `BlePodMonitor.devices + AapConnectionManager.allStates + DeviceStateCache + profiles` into unified `PodDevice` objects. ViewModels observe `DeviceMonitor.devices`; they do **not** reach into `BlePodMonitor` or the AAP layer directly
|
||||||
|
- `MonitorControl` / `MonitorService` — foreground service lifecycle holding the scan awake
|
||||||
|
- `BluetoothEventReceiver`, `BootCompletedReceiver` — system triggers that wake the service
|
||||||
|
|
||||||
|
**BLE vs AAP — what each path gives you:**
|
||||||
|
|
||||||
|
| | BLE (advertisements) | AAP (L2CAP session) |
|
||||||
|
|---|---|---|
|
||||||
|
| Direction | Read-only, passive | Bidirectional commands + events |
|
||||||
|
| Prerequisite | Bluetooth on | Bonded + `BLUETOOTH_CONNECT` + active L2CAP socket |
|
||||||
|
| Data | Battery, case open, in-ear, pod model | Settings, ANC mode control, press controls, stem events, device info |
|
||||||
|
| Availability | Any pod in range | Only your own paired pods |
|
||||||
|
|
||||||
|
### Reaction System
|
||||||
|
|
||||||
|
- `ReactionsCard`: Compose UI for reaction settings, embedded in the device settings screen
|
||||||
|
- `PopUpWindow`: Displays AirPods status when case is opened
|
||||||
|
- `PopUpContent`: Compose pod rendering — model-specific UI branches inline, no factory class
|
||||||
|
|
||||||
|
### Widget System (Glance)
|
||||||
|
|
||||||
|
- `BatteryGlanceWidget`, `AncGlanceWidget`: Jetpack Glance-based home-screen widgets
|
||||||
|
- `WidgetConfigurationActivity`: Configuration UI launched on widget placement
|
||||||
|
- Lives under `app/src/main/java/eu/darken/capod/main/ui/widget/`
|
||||||
|
|
||||||
|
### Upgrade / Pro Features
|
||||||
|
|
||||||
|
- `UpgradeRepo` interface with two flavor implementations:
|
||||||
|
- `UpgradeRepoGplay` — billing-client backed, includes grace-period handling for interrupted purchases
|
||||||
|
- `UpgradeControlFoss` — cache/sponsor-backed; users are `isPro = false` until they call `upgrade()`, after which the pro flag is persisted via DataStore
|
||||||
|
- FOSS is **not** "always pro" — it's opt-in via a local sponsor flow
|
||||||
|
|
||||||
|
### AAP (Apple Accessory Protocol) Stack
|
||||||
|
|
||||||
|
Three-layer structure under `pods/core/apple/aap/`:
|
||||||
|
|
||||||
|
- **`protocol/`** — pure data: `AapMessage`, `AapCommand`, `AapSetting`, `AapDeviceProfile`, `AapDeviceInfo`, `StemPressEvent`, `KeyExchangeResult`. Plus `DefaultAapDeviceProfile` and `Model.Features` capturing per-model capability
|
||||||
|
- **`engine/`** — session state machine for one connection:
|
||||||
|
- `AapConnection` — the L2CAP socket wrapper
|
||||||
|
- `AapSessionEngine` — drives the session lifecycle; tested in `AapSessionEngineTest`
|
||||||
|
- `AapInboundInterpreter` / `AapOutboundController` — decode incoming messages, encode outgoing
|
||||||
|
- `AapSettingsCoordinator`, `AapAncController`, `HidTracker`, `AapDeviceInfoDiagnostics` — feature-specific coordinators that sit on top of the session
|
||||||
|
- **`AapConnectionManager`** (singleton) — owns all open AAP sessions keyed by `BluetoothAddress`, uses `L2capSocketFactory` to create sockets. Consumers don't touch `AapConnection` directly — they call `sendCommand(...)` and observe `allStates`
|
||||||
|
|
||||||
|
The monitor-layer glue (`monitor/core/aap/`) described above wires this stack into the foreground service and persists its learned state.
|
||||||
|
|
||||||
|
### Common Utilities
|
||||||
|
|
||||||
|
- `EdgeToEdgeHelper`: Handles Android edge-to-edge display insets
|
||||||
|
|
||||||
|
## Build Configuration
|
||||||
|
|
||||||
|
### Flavors
|
||||||
|
|
||||||
|
- **FOSS**: Open-source version without Google Play dependencies
|
||||||
|
- **Google Play (gplay)**: Version with billing client for in-app purchases
|
||||||
|
|
||||||
|
### Build Types
|
||||||
|
|
||||||
|
- **debug**: Unobfuscated, full logging, no minification
|
||||||
|
- **beta**: Obfuscated, production-ready with strict lint checks
|
||||||
|
- **release**: Fully optimized for production distribution
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. `BluetoothEventReceiver` / `BootCompletedReceiver` wake `MonitorService` (foreground)
|
||||||
|
2. `MonitorService` keeps `BlePodMonitor` scanning (passive advertisements) and `AapLifecycleManager` running (active L2CAP sessions via `AapConnectionManager`)
|
||||||
|
3. `DeviceMonitor` merges BLE + AAP + cached state + profiles into `PodDevice` objects
|
||||||
|
4. ViewModels (`OverviewViewModel`, `DeviceSettingsViewModel`, `PressControlsViewModel`, widget view models) observe `DeviceMonitor.devices`; settings/command changes are sent back through `AapConnectionManager.sendCommand(...)`
|
||||||
|
5. Reaction triggers (case-open popup, auto-play, notifications) and widget state updates react to the merged flow
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit Tests**: `app/src/test/` (shared), `app/src/testFoss/`, `app/src/testGplay/` (flavor-specific — e.g. `UpgradeRepoGplayTest`, `FossUpgradeSerializationTest`)
|
||||||
|
- **Screenshot Tests**: `app/src/screenshotTest/` — Compose Preview Screenshot Testing, powers the Play Store screenshot pipeline
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- **Hilt**: Dependency injection framework
|
||||||
|
- **Navigation**: Navigation3 (`addNavigation3()`) drives current Compose screen routing. Some legacy `androidx.navigation` helpers still exist (`NavDirectionsExtensions`, `ViewModel3`) — don't assume SafeArgs is fully gone
|
||||||
|
- **kotlinx.serialization**: JSON serialization for configuration and caching
|
||||||
|
- **Material Design 3**: Compose Material3 UI components
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
description: Build, test, lint, and release commands for Gradle
|
||||||
|
globs:
|
||||||
|
- "**/*.gradle.kts"
|
||||||
|
- "**/*.gradle"
|
||||||
|
- "gradle/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Build Commands
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build debug version
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Build all variants (FOSS and Google Play flavors)
|
||||||
|
./gradlew assemble
|
||||||
|
|
||||||
|
# Build specific flavor and type
|
||||||
|
./gradlew assembleFossDebug
|
||||||
|
./gradlew assembleGplayRelease
|
||||||
|
|
||||||
|
# Build app bundles for Play Store
|
||||||
|
./gradlew bundleGplayRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all unit tests
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Run unit tests for specific variant
|
||||||
|
./gradlew testFossDebugUnitTest
|
||||||
|
|
||||||
|
# Run instrumentation tests (requires connected device/emulator)
|
||||||
|
./gradlew connectedAndroidTest
|
||||||
|
./gradlew connectedFossDebugAndroidTest
|
||||||
|
|
||||||
|
# Run all checks (lint + tests)
|
||||||
|
./gradlew check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run lint for all variants
|
||||||
|
./gradlew lint
|
||||||
|
|
||||||
|
# Run lint for specific variant
|
||||||
|
./gradlew lintFossDebug
|
||||||
|
|
||||||
|
# Auto-fix lint issues where possible
|
||||||
|
./gradlew lintFix
|
||||||
|
|
||||||
|
# Update lint baseline
|
||||||
|
./gradlew updateLintBaseline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleFossRelease assembleGplayRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use `assembleFossDebug` as the default quick-check build (fastest variant)
|
||||||
|
- Run `./gradlew check` before submitting changes to catch lint and test issues
|
||||||
|
- Instrumentation tests require a connected device or running emulator
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: Git commit message format and conventions
|
||||||
|
globs:
|
||||||
|
- "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Guidelines
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<prefix>: <Short summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Summary line should be concise and describe the change. No period at the end.
|
||||||
|
|
||||||
|
## Prefixes
|
||||||
|
|
||||||
|
Use the existing commit history as reference. Common prefixes:
|
||||||
|
|
||||||
|
- **fix**: Bug fixes (e.g., `fix: Handle display cutouts in landscape mode`)
|
||||||
|
- **feat**: New features
|
||||||
|
- **refactor**: Code restructuring without behavior change
|
||||||
|
- **ui**: Visual/layout-only tweaks that aren't a full feature or refactor (e.g., `ui(overview): ...`)
|
||||||
|
- **chore**: Maintenance, dependency updates, build config
|
||||||
|
- **docs**: Documentation changes
|
||||||
|
|
||||||
|
## Component Scope (optional)
|
||||||
|
|
||||||
|
When a change is scoped to a specific area, include it after the prefix:
|
||||||
|
|
||||||
|
- `fix(widget): Fix layout for devices with single charge detection`
|
||||||
|
- `feat(monitor): Add battery level caching`
|
||||||
|
- `refactor(popup): Extract pod view factory`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Keep the summary line under 72 characters
|
||||||
|
- Use imperative mood ("Add feature" not "Added feature")
|
||||||
|
- Reference issue numbers when applicable
|
||||||
|
- Do not include `Co-authored-by` trailers
|
||||||
|
- Look at recent `git log` output to match the project's existing style
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
description: Guidelines for adding and naming Android string resources
|
||||||
|
globs:
|
||||||
|
- "**/res/values*/strings.xml"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Localization Guidelines
|
||||||
|
|
||||||
|
When adding new user-facing strings:
|
||||||
|
|
||||||
|
- **Always use string resources**: Never hardcode user-facing text in layouts or code
|
||||||
|
- **Follow naming conventions**: Use descriptive, hierarchical naming (e.g., `profiles_name_default`, `settings_monitor_mode_label`)
|
||||||
|
- **Provide context**: String names should indicate usage and location
|
||||||
|
- **Consider pluralization**: Use Android plural resources (`<plurals>`) when quantities vary
|
||||||
|
|
||||||
|
## Naming Examples
|
||||||
|
|
||||||
|
- `profiles_create_title` (screen title)
|
||||||
|
- `profiles_name_label` (form field label)
|
||||||
|
- `profiles_name_default` (default value)
|
||||||
|
- `troubleshooter_ble_result_failure_title` (status/error title)
|
||||||
|
|
||||||
|
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
description: Pull request naming and description conventions
|
||||||
|
globs:
|
||||||
|
- "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pull Request Guidelines
|
||||||
|
|
||||||
|
## PR Title Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<Category>: <Short user-facing summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
PR titles appear in auto-generated changelogs and are read by users. Use **ELI5, user-facing language** — no internal class names, library names, or implementation details.
|
||||||
|
|
||||||
|
## Category Prefixes
|
||||||
|
|
||||||
|
| Prefix | Covers |
|
||||||
|
|--------|--------|
|
||||||
|
| **Widget** | Home screen widget |
|
||||||
|
| **Reaction** | Case-open popup, auto-play/pause, notification triggers |
|
||||||
|
| **Device** | AirPods detection, compatibility, Bluetooth scanning, battery reading, device profiles |
|
||||||
|
| **General** | Dashboard, settings, notifications, themes, onboarding, support, app-wide UI |
|
||||||
|
| **Fix** | Bug fixes spanning multiple areas |
|
||||||
|
|
||||||
|
### Title Examples
|
||||||
|
|
||||||
|
- `Widget: Add color themes and transparency slider`
|
||||||
|
- `Reaction: Fix popup appearing twice when opening AirPods case`
|
||||||
|
- `Device: Add support for AirPods 4 with ANC`
|
||||||
|
- `General: Add dark mode and color theme settings`
|
||||||
|
- `Fix: Fix battery display stuck at 0% after reconnecting`
|
||||||
|
|
||||||
|
### Bad Titles (too technical)
|
||||||
|
|
||||||
|
- `refactor(settings): Migrate preferences to AndroidX DataStore`
|
||||||
|
- `feat(widget): Migrate to Jetpack Glance`
|
||||||
|
- `refactor(ui): Migrate from Fragments to Jetpack Compose`
|
||||||
|
|
||||||
|
## PR Description Format
|
||||||
|
|
||||||
|
PRs are reviewed in **GitHub's web UI**, which already shows the file tree, the diff, and the tests. Don't duplicate any
|
||||||
|
of it. The description should answer questions the diff can't — not restate it.
|
||||||
|
|
||||||
|
Only these sections, in this order:
|
||||||
|
|
||||||
|
1. `## What changed`
|
||||||
|
2. `## Technical Context`
|
||||||
|
3. `## Review checklist` *(optional)*
|
||||||
|
|
||||||
|
No `Scope`, `Files changed`, `Tests`, or `Review guidance` sub-sections — GitHub shows the files and tests, and review
|
||||||
|
notes belong in the checklist. Fold anything critical into a Technical Context bullet.
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
User-friendly explanation of what this PR does. Describe the problem that was fixed or the feature that was added from the user's perspective. No internal class or method names.
|
||||||
|
|
||||||
|
For non-user-facing PRs (refactors, tests, CI, dependency bumps): write "No user-facing behavior change" followed by a brief internal description.
|
||||||
|
|
||||||
|
### Technical Context
|
||||||
|
|
||||||
|
Explain what's hard to extract from the diff alone. Focus on:
|
||||||
|
|
||||||
|
- **Why** this approach was chosen (and alternatives considered/rejected)
|
||||||
|
- **Root cause** for bug fixes (the diff shows the fix, not what caused it)
|
||||||
|
- **Non-obvious side effects** or behavioral changes not apparent from reading the code
|
||||||
|
|
||||||
|
Format rules:
|
||||||
|
|
||||||
|
- **One bullet per point.** No prose paragraphs, no nested sub-headers like `**Bug 1** / **Bug 2**` — if a PR fixes
|
||||||
|
multiple bugs, one bullet per bug is enough.
|
||||||
|
- **Don't restate the diff.** File paths, class renames, test names, and line-level changes are all visible in the web
|
||||||
|
UI.
|
||||||
|
|
||||||
|
### Review checklist (optional)
|
||||||
|
|
||||||
|
For PRs with multiple non-trivial review points, add a `## Review checklist` section with `- [ ]` tasks the reviewer can
|
||||||
|
tick off as they verify. Skip it for small PRs — a single tricky thing can stay as a Technical Context bullet.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Fixed a crash that could happen when the AirPods case is opened while Bluetooth is turning off.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
- Root cause: `MonitorService` continued processing scan results during Bluetooth adapter state change, hitting a null adapter reference
|
||||||
|
- Chose to gate on adapter state in the scan callback rather than adding a separate BroadcastReceiver, since the service already observes adapter state for restart logic
|
||||||
|
- The timing window is ~200ms between ACTION_STATE_CHANGING and ACTION_STATE_OFF — only reproducible on Pixel devices with aggressive Bluetooth power management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Issue references**: Use "Closes #123", "Fixes #123", or "Resolves #123"
|
||||||
|
- **Breaking changes**: Mark with "BREAKING:" prefix if applicable
|
||||||
|
- **No `Co-authored-by` trailers** (per project convention)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Release Process
|
||||||
|
|
||||||
|
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
|
||||||
|
|
||||||
|
## Dispatch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Plan only — no commit, no tag, no push.
|
||||||
|
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
|
||||||
|
|
||||||
|
# Real cut.
|
||||||
|
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
|
||||||
|
```
|
||||||
|
|
||||||
|
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
| Input | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` |
|
||||||
|
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
|
||||||
|
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
|
||||||
|
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
|
||||||
|
| `dry_run` | `true` | Default is plan-only. |
|
||||||
|
|
||||||
|
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
|
||||||
|
|
||||||
|
## Local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current
|
||||||
|
./tools/release/bump.sh --mode=check
|
||||||
|
bats tools/release/bump.bats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Channel mapping
|
||||||
|
|
||||||
|
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
|
||||||
|
| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
|
||||||
|
|
||||||
|
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
| Stage reached | Steps |
|
||||||
|
|---|---|
|
||||||
|
| Bump on `main`, downstream not started | `git push origin :refs/tags/v<bad>`, `git revert <bump-sha>`, push |
|
||||||
|
| GitHub release created | Above + `gh release delete v<bad> --yes --cleanup-tag` |
|
||||||
|
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
|
||||||
|
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
|
||||||
|
|
||||||
|
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
|
||||||
|
|
||||||
|
## Auth setup
|
||||||
|
|
||||||
|
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
|
||||||
|
|
||||||
|
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
|
||||||
|
|
||||||
|
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
|
||||||
|
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
|
||||||
|
|
||||||
|
The App is installed on this repo and added as a bypass actor to:
|
||||||
|
- The main-branch ruleset (PR + status check requirements)
|
||||||
|
- The tag ruleset (creation restriction on `v*`)
|
||||||
|
|
||||||
|
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
|
||||||
|
|
||||||
|
## Defense in depth
|
||||||
|
|
||||||
|
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
|
||||||
|
|
||||||
|
## Stuck-dispatch recovery
|
||||||
|
|
||||||
|
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Play Store Screenshot Pipeline
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Localized screenshots are generated using Compose Preview Screenshot Testing (alpha), rendered offline (no device needed), and sorted into fastlane metadata directories for Play Store upload.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
ScreenshotContent.kt (mock data + composables)
|
||||||
|
→ PlayStoreScreenshots.kt (@PreviewTest entry points)
|
||||||
|
→ PlayStoreLocales.kt (multi-preview locale annotations, auto-generated per batch)
|
||||||
|
→ generate_screenshots.sh (batched Gradle runs to avoid OOM)
|
||||||
|
→ copy_screenshots.sh (sort PNGs into fastlane locale dirs)
|
||||||
|
→ fastlane/metadata/android/{locale}/images/phoneScreenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables for each screen (7 screens) |
|
||||||
|
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
|
||||||
|
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
|
||||||
|
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
|
||||||
|
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
|
||||||
|
|
||||||
|
## Commit policy
|
||||||
|
|
||||||
|
Only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
|
||||||
|
|
||||||
|
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
|
||||||
|
# Use this for local iteration and PRs that touch screenshot content.
|
||||||
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
|
|
||||||
|
# Full run — all 68 locales. Use only when intending to upload to Play Store
|
||||||
|
# (the non-smoke output is .gitignored and should not be committed).
|
||||||
|
./fastlane/generate_screenshots.sh
|
||||||
|
|
||||||
|
# Copy into fastlane directories (run after generate)
|
||||||
|
./fastlane/copy_screenshots.sh
|
||||||
|
|
||||||
|
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a New Screenshot
|
||||||
|
|
||||||
|
1. Add a composable content function in `ScreenshotContent.kt` (e.g. `NewScreenContent()`)
|
||||||
|
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
|
||||||
|
3. Add the function name → filename mapping in `copy_screenshots.sh` `SCREEN_MAP`
|
||||||
|
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
|
||||||
|
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
|
||||||
|
|
||||||
|
## Removing or Renaming a Screenshot
|
||||||
|
|
||||||
|
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
|
||||||
|
2. Run `generate_screenshots.sh --smoke`
|
||||||
|
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
|
||||||
|
|
||||||
|
## After UI Changes
|
||||||
|
|
||||||
|
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fastlane/generate_screenshots.sh --smoke
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refreshing all locales on Play Store
|
||||||
|
|
||||||
|
Periodic, manual operation — not per-PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fastlane/generate_screenshots.sh # full, ~30 min, 477 PNGs
|
||||||
|
./fastlane/copy_screenshots.sh --clean
|
||||||
|
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
|
||||||
|
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
|
||||||
|
- Gradle daemon is stopped between batches to release memory
|
||||||
|
- `PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
|
||||||
|
- Device spec: 1080x2400px @ 428 DPI (Pixel-class phone)
|
||||||
|
- Uses `com.android.compose.screenshot` plugin v0.0.1-alpha13
|
||||||
|
- Output: `app/src/screenshotTestGplayDebug/reference/`
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__ide__getDiagnostics",
|
||||||
|
"WebSearch"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"android-translation@claude-code-cafe": true,
|
||||||
|
"debugbadger@claude-code-cafe": true,
|
||||||
|
"jvm-tools@claude-code-cafe": true,
|
||||||
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"kotlin-lsp@claude-plugins-official": true,
|
||||||
|
"support-investigator@claude-code-cafe": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 #v4.7.0
|
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
@@ -14,7 +14,7 @@ runs:
|
|||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
||||||
- name: Cache Gradle Wrapper
|
- name: Cache Gradle Wrapper
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
@@ -24,19 +24,10 @@ runs:
|
|||||||
${{ runner.os }}-gradle-wrapper-
|
${{ runner.os }}-gradle-wrapper-
|
||||||
|
|
||||||
- name: Cache Gradle Dependencies
|
- name: Cache Gradle Dependencies
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 #v5.0.3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-caches-
|
${{ runner.os }}-gradle-caches-
|
||||||
|
|
||||||
- name: Cache Android Global Build-Cache
|
|
||||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 #v4.2.3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.android/build-cache
|
|
||||||
key: ${{ runner.os }}-android-build-cache-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-android-build-cache-
|
|
||||||
@@ -3,9 +3,15 @@ name: Code tests & eval
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-vital:
|
lint-vital:
|
||||||
name: Lint vitals
|
name: Lint vitals
|
||||||
@@ -18,7 +24,9 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -36,7 +44,9 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
@@ -48,14 +58,50 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
variant: [ Debug,Beta,Release ]
|
variant: [ Debug ]
|
||||||
flavor: [ testFoss,testGplay ]
|
flavor: [ testFoss,testGplay ]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Test modules
|
- name: Test modules
|
||||||
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
run: ./gradlew ${{ matrix.flavor }}${{ matrix.variant }}UnitTest
|
||||||
|
|
||||||
|
check-fastlane-metadata:
|
||||||
|
name: Fastlane metadata
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Validate metadata lengths
|
||||||
|
run: bash fastlane/check_metadata_length.sh
|
||||||
|
|
||||||
|
check-release-tooling:
|
||||||
|
name: Release tooling
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install bats and shellcheck
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y bats shellcheck
|
||||||
|
|
||||||
|
- name: Shellcheck bump.sh
|
||||||
|
run: shellcheck tools/release/bump.sh
|
||||||
|
|
||||||
|
- name: Run bump.sh unit tests
|
||||||
|
run: bats tools/release/bump.bats
|
||||||
|
|
||||||
|
- name: Verify version.properties + VERSION are consistent
|
||||||
|
run: ./tools/release/bump.sh --mode=check
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- VERSION
|
||||||
|
- version.properties
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validation:
|
validation:
|
||||||
name: "Validation"
|
name: "Validation"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 #v4.3.1
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Deploy GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- _config.yml
|
||||||
|
- _layouts/**
|
||||||
|
- README.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- PRIVACY_POLICY.md
|
||||||
|
- CNAME
|
||||||
|
- .github/workflows/pages.yml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Jekyll site
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Configure Pages
|
||||||
|
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
|
||||||
|
|
||||||
|
- name: Build with Jekyll
|
||||||
|
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 #v1.0.13
|
||||||
|
with:
|
||||||
|
source: ./
|
||||||
|
destination: ./_site
|
||||||
|
env:
|
||||||
|
JEKYLL_GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Sanity-check Jekyll output
|
||||||
|
run: test -f _site/index.html && test -f _site/CNAME
|
||||||
|
|
||||||
|
- name: Upload Pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #v5.0.0
|
||||||
|
with:
|
||||||
|
path: ./_site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
needs: build
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #v5.0.0
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
name: Release prepare
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump_kind:
|
||||||
|
description: 'How to bump the version (ignored if version_override is set)'
|
||||||
|
type: choice
|
||||||
|
options: [build, patch, minor, major]
|
||||||
|
default: build
|
||||||
|
version_type:
|
||||||
|
description: 'Channel for the new version (keep-current preserves current type)'
|
||||||
|
type: choice
|
||||||
|
options: [keep-current, rc, beta]
|
||||||
|
default: keep-current
|
||||||
|
version_override:
|
||||||
|
description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)'
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
expected_current:
|
||||||
|
description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)'
|
||||||
|
type: string
|
||||||
|
default: ''
|
||||||
|
dry_run:
|
||||||
|
description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-prepare-main
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compute-and-validate:
|
||||||
|
name: Compute and validate
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
new_name: ${{ steps.plan.outputs.new_name }}
|
||||||
|
new_code: ${{ steps.plan.outputs.new_code }}
|
||||||
|
current_name: ${{ steps.plan.outputs.current_name }}
|
||||||
|
env:
|
||||||
|
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||||
|
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||||
|
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||||
|
INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }}
|
||||||
|
steps:
|
||||||
|
- name: Guard ref must be main
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||||
|
echo "Must dispatch from main, got ${GITHUB_REF}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Compute and validate
|
||||||
|
id: plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
args=("--mode=plan")
|
||||||
|
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||||
|
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||||
|
else
|
||||||
|
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||||
|
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||||
|
fi
|
||||||
|
if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then
|
||||||
|
args+=("--expected-current=${INPUT_EXPECTED_CURRENT}")
|
||||||
|
fi
|
||||||
|
./tools/release/bump.sh "${args[@]}" | tee plan.txt
|
||||||
|
{
|
||||||
|
grep -E '^current_name=' plan.txt
|
||||||
|
grep -E '^new_name=' plan.txt
|
||||||
|
grep -E '^new_code=' plan.txt
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Tag collision check (local + remote)
|
||||||
|
env:
|
||||||
|
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||||
|
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||||
|
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Write step summary
|
||||||
|
env:
|
||||||
|
CURRENT_NAME: ${{ steps.plan.outputs.current_name }}
|
||||||
|
NEW_NAME: ${{ steps.plan.outputs.new_name }}
|
||||||
|
NEW_CODE: ${{ steps.plan.outputs.new_code }}
|
||||||
|
DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
{
|
||||||
|
echo "## Release plan"
|
||||||
|
echo
|
||||||
|
echo "| | |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| Current | \`${CURRENT_NAME}\` |"
|
||||||
|
echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |"
|
||||||
|
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||||
|
echo "| Dry run | \`${DRY_RUN}\` |"
|
||||||
|
echo
|
||||||
|
echo "### bump.sh output"
|
||||||
|
echo
|
||||||
|
echo '```'
|
||||||
|
cat plan.txt
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
push-and-dispatch:
|
||||||
|
name: Push and dispatch
|
||||||
|
needs: compute-and-validate
|
||||||
|
if: ${{ !inputs.dry_run }}
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
|
||||||
|
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
|
||||||
|
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
|
||||||
|
NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }}
|
||||||
|
NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }}
|
||||||
|
CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }}
|
||||||
|
steps:
|
||||||
|
- name: Mint App token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
|
||||||
|
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- name: Resolve bot identity
|
||||||
|
id: bot
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id)
|
||||||
|
echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Checkout main with credentials
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: true
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
- name: Verify state still matches plan from Job 1
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
|
||||||
|
|
||||||
|
- name: Re-check tag collision (state may have moved between jobs)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
|
||||||
|
echo "Local tag v${NEW_NAME} already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then
|
||||||
|
echo "Remote tag v${NEW_NAME} already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Apply bump
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}")
|
||||||
|
if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then
|
||||||
|
args+=("--version-override=${INPUT_VERSION_OVERRIDE}")
|
||||||
|
else
|
||||||
|
args+=("--bump-kind=${INPUT_BUMP_KIND}")
|
||||||
|
args+=("--version-type=${INPUT_VERSION_TYPE}")
|
||||||
|
fi
|
||||||
|
./tools/release/bump.sh "${args[@]}"
|
||||||
|
|
||||||
|
- name: Verify post-write state matches plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}"
|
||||||
|
|
||||||
|
- name: Configure git identity
|
||||||
|
env:
|
||||||
|
BOT_USER_NAME: ${{ steps.bot.outputs.user_name }}
|
||||||
|
BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }}
|
||||||
|
run: |
|
||||||
|
git config user.name "${BOT_USER_NAME}"
|
||||||
|
git config user.email "${BOT_USER_EMAIL}"
|
||||||
|
|
||||||
|
- name: Commit and tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git add version.properties VERSION
|
||||||
|
git commit -m "Release: ${NEW_NAME}"
|
||||||
|
git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}"
|
||||||
|
|
||||||
|
- name: Atomic push (commit + tag)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
|
||||||
|
|
||||||
|
- name: Write step summary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
{
|
||||||
|
echo "## Released"
|
||||||
|
echo
|
||||||
|
echo "| | |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| Tag | \`v${NEW_NAME}\` |"
|
||||||
|
echo "| Version code | \`${NEW_CODE}\` |"
|
||||||
|
echo "| Bump commit | on \`main\` |"
|
||||||
|
echo "| Downstream | dispatched \`release-tag.yml\` |"
|
||||||
|
echo
|
||||||
|
echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -4,12 +4,64 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: 'Build only, skip release/upload'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
validate-tag:
|
||||||
|
name: Validate tag
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Check tag-name format
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then
|
||||||
|
echo "Tag '${REF_NAME}' does not match v<M.m.p-(rc|beta)n>" >&2
|
||||||
|
echo "Releases must be cut via the 'Release prepare' workflow." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Verify version.properties matches tag
|
||||||
|
env:
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Strip leading 'v' to get the bare version name.
|
||||||
|
tag_name="${REF_NAME#v}"
|
||||||
|
# bump.sh in check mode emits current_name=...
|
||||||
|
parsed=$(./tools/release/bump.sh --mode=check)
|
||||||
|
current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2)
|
||||||
|
if [[ "$current_name" != "$tag_name" ]]; then
|
||||||
|
echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2
|
||||||
|
echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
release-github:
|
release-github:
|
||||||
|
needs: validate-tag
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
actions: write
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: foss-production
|
environment: foss-production
|
||||||
steps:
|
steps:
|
||||||
@@ -24,23 +76,16 @@ jobs:
|
|||||||
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(echo $TMP_KEYSTORE_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Get the version
|
|
||||||
id: tagger
|
|
||||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
|
||||||
with:
|
|
||||||
skip-unshallow: 'true'
|
|
||||||
abbrev: false
|
|
||||||
commit-ish: HEAD
|
|
||||||
|
|
||||||
- name: Assemble beta APK
|
- name: Assemble beta APK
|
||||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
if: contains(github.ref_name, '-beta')
|
||||||
run: ./gradlew assembleFossBeta
|
run: ./gradlew assembleFossBeta
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -49,7 +94,7 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Assemble production APK
|
- name: Assemble production APK
|
||||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
if: "!contains(github.ref_name, '-beta')"
|
||||||
run: ./gradlew assembleFossRelease
|
run: ./gradlew assembleFossRelease
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ github.ref }}
|
VERSION: ${{ github.ref }}
|
||||||
@@ -58,35 +103,42 @@ jobs:
|
|||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: ${{ steps.tagger.outputs.tag }}
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: app/build/outputs/apk/foss/beta/eu.darken.capod-*.apk
|
||||||
app/build/outputs/apk/foss/beta/*.apk
|
|
||||||
app-wear/build/outputs/apk/foss/beta/*.apk
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda #v2.2.1
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v3.0.0
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tag_name: ${{ steps.tagger.outputs.tag }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: ${{ steps.tagger.outputs.tag }}
|
name: ${{ github.ref_name }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: app/build/outputs/apk/foss/release/*.apk
|
files: app/build/outputs/apk/foss/release/eu.darken.capod-*.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Trigger GitHub Pages deployment
|
||||||
|
if: "!(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh workflow run pages.yml --ref main
|
||||||
|
|
||||||
release-gplay:
|
release-gplay:
|
||||||
|
needs: validate-tag
|
||||||
name: Create Google Play release
|
name: Create Google Play release
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
environment: gplay-production
|
environment: gplay-production
|
||||||
|
env:
|
||||||
|
BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile
|
||||||
steps:
|
steps:
|
||||||
- name: Decode Keystore
|
- name: Decode Keystore
|
||||||
env:
|
env:
|
||||||
@@ -109,53 +161,34 @@ jobs:
|
|||||||
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
echo "SUPPLY_JSON_KEY=$(echo $TMP_SERVICEKEY_FILE_PATH)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup project and build environment
|
- name: Setup project and build environment
|
||||||
uses: ./.github/actions/common-setup
|
uses: ./.github/actions/common-setup
|
||||||
|
|
||||||
- name: Get the version
|
|
||||||
id: tagger
|
|
||||||
uses: jimschubert/query-tag-action@0b288a5fff630fea2e96d61b99047ed823ca19dc #v2.2
|
|
||||||
with:
|
|
||||||
skip-unshallow: 'true'
|
|
||||||
abbrev: false
|
|
||||||
commit-ish: HEAD
|
|
||||||
|
|
||||||
- name: Set up ruby env
|
- name: Set up ruby env
|
||||||
uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 #v1.229.0
|
uses: ruby/setup-ruby@4eb9f110bac952a8b68ecf92e3b5c7a987594ba6 #v1.292.0
|
||||||
with:
|
with:
|
||||||
ruby-version: 3.3.6
|
ruby-version: 3.3.6
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
working-directory: fastlane
|
||||||
|
|
||||||
# - name: Assemble WearOS beta and upload to Google Play
|
- name: Verify fastlane Bundler wiring
|
||||||
# if: contains(steps.tagger.outputs.tag, '-beta')
|
run: bundle exec fastlane --version
|
||||||
# run: bundle exec fastlane beta_wearos
|
|
||||||
# env:
|
|
||||||
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
|
||||||
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
|
||||||
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Assemble beta and upload to Google Play
|
- name: Assemble beta and upload to Google Play
|
||||||
if: contains(steps.tagger.outputs.tag, '-beta')
|
if: contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||||
run: bundle exec fastlane beta
|
run: bundle exec fastlane beta
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
|
||||||
# - name: Assemble WearOS production and upload to Google Play
|
|
||||||
# if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
|
||||||
# run: bundle exec fastlane production_wearos
|
|
||||||
# env:
|
|
||||||
# STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
|
||||||
# KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
|
||||||
# KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Assemble production and upload to Google Play
|
- name: Assemble production and upload to Google Play
|
||||||
if: "!contains(steps.tagger.outputs.tag, '-beta')"
|
if: "!contains(github.ref_name, '-beta') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)"
|
||||||
run: bundle exec fastlane production
|
run: bundle exec fastlane production
|
||||||
env:
|
env:
|
||||||
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
|
||||||
|
|||||||
+20
-1
@@ -12,4 +12,23 @@
|
|||||||
*.jks
|
*.jks
|
||||||
.local/*
|
.local/*
|
||||||
/fastlane/report.xml
|
/fastlane/report.xml
|
||||||
/fastlane/Appfile
|
/fastlane/Appfile
|
||||||
|
/fastlane/README.md
|
||||||
|
.kotlin
|
||||||
|
# Screenshot test reference images (ephemeral, regenerated on demand)
|
||||||
|
app/src/screenshotTest*/reference/
|
||||||
|
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
|
||||||
|
# Full localization is uploaded by occasional manual regen + screenshots_only;
|
||||||
|
# Play Store retains previously-uploaded screenshots for locales not pushed.
|
||||||
|
fastlane/metadata/android/*/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
|
||||||
|
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
|
||||||
|
.codex
|
||||||
|
protocol-research/
|
||||||
|
_site/
|
||||||
|
.jekyll-cache/
|
||||||
|
vendor/bundle/
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
layout: plain
|
||||||
|
permalink: /changelog
|
||||||
|
title: "Changelog"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Changelog for CAPod
|
||||||
|
|
||||||
|
{% for release in site.github.releases %}
|
||||||
|
|
||||||
|
## {{ release.tag_name }} - {{ release.published_at | date: "%B %d, %Y" }}
|
||||||
|
|
||||||
|
{% assign clean_body = release.body | strip %}
|
||||||
|
{% assign no_comments = clean_body | replace: "<!-- Release notes generated using configuration in .github/release.yml", "" %}
|
||||||
|
{% assign no_comments = no_comments | split: "-->" %}
|
||||||
|
{% if no_comments.size > 1 %}
|
||||||
|
{% assign clean_content = no_comments[1] | strip %}
|
||||||
|
{% else %}
|
||||||
|
{% assign clean_content = no_comments[0] | strip %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %} Make links clickable {% endcomment %}
|
||||||
|
{% assign lines = clean_content | split: "
|
||||||
|
" %}
|
||||||
|
{% assign processed_lines = "" %}
|
||||||
|
{% for line in lines %}
|
||||||
|
{% if line contains "**Full Changelog**:" %}
|
||||||
|
{% comment %} Handle Full Changelog links {% endcomment %}
|
||||||
|
{% assign parts = line | split: ": " %}
|
||||||
|
{% if parts.size > 1 %}
|
||||||
|
{% assign url = parts[1] | strip %}
|
||||||
|
{% assign clickable_line = "**[View Changes](" | append: url | append: ")**" %}
|
||||||
|
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
||||||
|
" %}
|
||||||
|
{% else %}
|
||||||
|
{% assign processed_lines = processed_lines | append: line | append: "
|
||||||
|
" %}
|
||||||
|
{% endif %}
|
||||||
|
{% elsif line contains " in https://github.com/" and line contains "/pull/" %}
|
||||||
|
{% comment %} Handle pull request links {% endcomment %}
|
||||||
|
{% assign pr_parts = line | split: " in https://github.com/" %}
|
||||||
|
{% if pr_parts.size > 1 %}
|
||||||
|
{% assign before_url = pr_parts[0] %}
|
||||||
|
{% assign after_url = pr_parts[1] %}
|
||||||
|
{% assign url = "https://github.com/" | append: after_url %}
|
||||||
|
{% assign pr_number = after_url | split: "/pull/" %}
|
||||||
|
{% if pr_number.size > 1 %}
|
||||||
|
{% assign pr_num = pr_number[1] | split: " " | first %}
|
||||||
|
{% assign clickable_line = before_url | append: " in [#" | append: pr_num | append: "](" | append: url | append: ")" %}
|
||||||
|
{% assign processed_lines = processed_lines | append: clickable_line | append: "
|
||||||
|
" %}
|
||||||
|
{% else %}
|
||||||
|
{% assign processed_lines = processed_lines | append: line | append: "
|
||||||
|
" %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% assign processed_lines = processed_lines | append: line | append: "
|
||||||
|
" %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% assign processed_lines = processed_lines | append: line | append: "
|
||||||
|
" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% comment %} Add proper spacing between sections and bullet points {% endcomment %}
|
||||||
|
{% assign final_content = processed_lines | replace: "
|
||||||
|
### ", "
|
||||||
|
|
||||||
|
### " %}
|
||||||
|
{% assign final_content = final_content | replace: "
|
||||||
|
## ", "
|
||||||
|
|
||||||
|
## " %}
|
||||||
|
{% assign final_content = final_content | replace: "
|
||||||
|
- ", "
|
||||||
|
|
||||||
|
- " %}
|
||||||
|
|
||||||
|
{% comment %} Check if there are any bullet points (actual release notes) {% endcomment %}
|
||||||
|
{% if final_content contains "## " or final_content contains "- " %}
|
||||||
|
{{ final_content | markdownify }}
|
||||||
|
{% else %}
|
||||||
|
*No release notes available.*
|
||||||
|
|
||||||
|
{{ final_content | markdownify }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
---
|
||||||
|
{% endfor %}
|
||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
GEM
|
|
||||||
remote: https://rubygems.org/
|
|
||||||
specs:
|
|
||||||
CFPropertyList (3.0.6)
|
|
||||||
rexml
|
|
||||||
addressable (2.8.4)
|
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
|
||||||
artifactory (3.0.15)
|
|
||||||
atomos (0.1.3)
|
|
||||||
aws-eventstream (1.2.0)
|
|
||||||
aws-partitions (1.784.0)
|
|
||||||
aws-sdk-core (3.177.0)
|
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
|
||||||
aws-sigv4 (~> 1.5)
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
|
||||||
aws-sdk-kms (1.70.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.177.0)
|
|
||||||
aws-sigv4 (~> 1.1)
|
|
||||||
aws-sdk-s3 (1.128.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.177.0)
|
|
||||||
aws-sdk-kms (~> 1)
|
|
||||||
aws-sigv4 (~> 1.6)
|
|
||||||
aws-sigv4 (1.6.0)
|
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
|
||||||
babosa (1.0.4)
|
|
||||||
claide (1.1.0)
|
|
||||||
colored (1.2)
|
|
||||||
colored2 (3.1.2)
|
|
||||||
commander (4.6.0)
|
|
||||||
highline (~> 2.0.0)
|
|
||||||
declarative (0.0.20)
|
|
||||||
digest-crc (0.6.5)
|
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
|
||||||
domain_name (0.5.20190701)
|
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
|
||||||
emoji_regex (3.2.3)
|
|
||||||
excon (0.100.0)
|
|
||||||
faraday (1.10.3)
|
|
||||||
faraday-em_http (~> 1.0)
|
|
||||||
faraday-em_synchrony (~> 1.0)
|
|
||||||
faraday-excon (~> 1.1)
|
|
||||||
faraday-httpclient (~> 1.0)
|
|
||||||
faraday-multipart (~> 1.0)
|
|
||||||
faraday-net_http (~> 1.0)
|
|
||||||
faraday-net_http_persistent (~> 1.0)
|
|
||||||
faraday-patron (~> 1.0)
|
|
||||||
faraday-rack (~> 1.0)
|
|
||||||
faraday-retry (~> 1.0)
|
|
||||||
ruby2_keywords (>= 0.0.4)
|
|
||||||
faraday-cookie_jar (0.0.7)
|
|
||||||
faraday (>= 0.8.0)
|
|
||||||
http-cookie (~> 1.0.0)
|
|
||||||
faraday-em_http (1.0.0)
|
|
||||||
faraday-em_synchrony (1.0.0)
|
|
||||||
faraday-excon (1.1.0)
|
|
||||||
faraday-httpclient (1.0.1)
|
|
||||||
faraday-multipart (1.0.4)
|
|
||||||
multipart-post (~> 2)
|
|
||||||
faraday-net_http (1.0.1)
|
|
||||||
faraday-net_http_persistent (1.2.0)
|
|
||||||
faraday-patron (1.0.0)
|
|
||||||
faraday-rack (1.0.0)
|
|
||||||
faraday-retry (1.0.3)
|
|
||||||
faraday_middleware (1.2.0)
|
|
||||||
faraday (~> 1.0)
|
|
||||||
fastimage (2.2.7)
|
|
||||||
fastlane (2.213.0)
|
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
|
||||||
addressable (>= 2.8, < 3.0.0)
|
|
||||||
artifactory (~> 3.0)
|
|
||||||
aws-sdk-s3 (~> 1.0)
|
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
|
||||||
bundler (>= 1.12.0, < 3.0.0)
|
|
||||||
colored
|
|
||||||
commander (~> 4.6)
|
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
|
||||||
faraday (~> 1.0)
|
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
|
||||||
faraday_middleware (~> 1.0)
|
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
|
||||||
google-cloud-storage (~> 1.31)
|
|
||||||
highline (~> 2.0)
|
|
||||||
json (< 3.0.0)
|
|
||||||
jwt (>= 2.1.0, < 3)
|
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
|
||||||
naturally (~> 2.2)
|
|
||||||
optparse (~> 0.1.1)
|
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
|
||||||
security (= 0.1.3)
|
|
||||||
simctl (~> 1.6.3)
|
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
|
||||||
terminal-table (>= 1.4.5, < 2.0.0)
|
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
|
||||||
word_wrap (~> 1.0.0)
|
|
||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
|
||||||
xcpretty (~> 0.3.0)
|
|
||||||
xcpretty-travis-formatter (>= 0.0.3)
|
|
||||||
gh_inspector (1.1.3)
|
|
||||||
google-apis-androidpublisher_v3 (0.45.0)
|
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
|
||||||
google-apis-core (0.11.0)
|
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
|
||||||
mini_mime (~> 1.0)
|
|
||||||
representable (~> 3.0)
|
|
||||||
retriable (>= 2.0, < 4.a)
|
|
||||||
rexml
|
|
||||||
webrick
|
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
|
||||||
google-apis-storage_v1 (0.19.0)
|
|
||||||
google-apis-core (>= 0.9.0, < 2.a)
|
|
||||||
google-cloud-core (1.6.0)
|
|
||||||
google-cloud-env (~> 1.0)
|
|
||||||
google-cloud-errors (~> 1.0)
|
|
||||||
google-cloud-env (1.6.0)
|
|
||||||
faraday (>= 0.17.3, < 3.0)
|
|
||||||
google-cloud-errors (1.3.1)
|
|
||||||
google-cloud-storage (1.44.0)
|
|
||||||
addressable (~> 2.8)
|
|
||||||
digest-crc (~> 0.4)
|
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
|
||||||
google-apis-storage_v1 (~> 0.19.0)
|
|
||||||
google-cloud-core (~> 1.6)
|
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
|
||||||
mini_mime (~> 1.0)
|
|
||||||
googleauth (1.6.0)
|
|
||||||
faraday (>= 0.17.3, < 3.a)
|
|
||||||
jwt (>= 1.4, < 3.0)
|
|
||||||
memoist (~> 0.16)
|
|
||||||
multi_json (~> 1.11)
|
|
||||||
os (>= 0.9, < 2.0)
|
|
||||||
signet (>= 0.16, < 2.a)
|
|
||||||
highline (2.0.3)
|
|
||||||
http-cookie (1.0.5)
|
|
||||||
domain_name (~> 0.5)
|
|
||||||
httpclient (2.8.3)
|
|
||||||
jmespath (1.6.2)
|
|
||||||
json (2.6.3)
|
|
||||||
jwt (2.7.1)
|
|
||||||
memoist (0.16.2)
|
|
||||||
mini_magick (4.12.0)
|
|
||||||
mini_mime (1.1.2)
|
|
||||||
multi_json (1.15.0)
|
|
||||||
multipart-post (2.3.0)
|
|
||||||
nanaimo (0.3.0)
|
|
||||||
naturally (2.2.1)
|
|
||||||
optparse (0.1.1)
|
|
||||||
os (1.1.4)
|
|
||||||
plist (3.7.0)
|
|
||||||
public_suffix (5.0.1)
|
|
||||||
rake (13.0.6)
|
|
||||||
representable (3.2.0)
|
|
||||||
declarative (< 0.1.0)
|
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
|
||||||
uber (< 0.2.0)
|
|
||||||
retriable (3.1.2)
|
|
||||||
rexml (3.2.5)
|
|
||||||
rouge (2.0.7)
|
|
||||||
ruby2_keywords (0.0.5)
|
|
||||||
rubyzip (2.3.2)
|
|
||||||
security (0.1.3)
|
|
||||||
signet (0.17.0)
|
|
||||||
addressable (~> 2.8)
|
|
||||||
faraday (>= 0.17.5, < 3.a)
|
|
||||||
jwt (>= 1.5, < 3.0)
|
|
||||||
multi_json (~> 1.10)
|
|
||||||
simctl (1.6.10)
|
|
||||||
CFPropertyList
|
|
||||||
naturally
|
|
||||||
terminal-notifier (2.0.0)
|
|
||||||
terminal-table (1.8.0)
|
|
||||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
|
||||||
trailblazer-option (0.1.2)
|
|
||||||
tty-cursor (0.7.1)
|
|
||||||
tty-screen (0.8.1)
|
|
||||||
tty-spinner (0.9.3)
|
|
||||||
tty-cursor (~> 0.7)
|
|
||||||
uber (0.1.0)
|
|
||||||
unf (0.1.4)
|
|
||||||
unf_ext
|
|
||||||
unf_ext (0.0.8.2)
|
|
||||||
unicode-display_width (1.8.0)
|
|
||||||
webrick (1.8.1)
|
|
||||||
word_wrap (1.0.0)
|
|
||||||
xcodeproj (1.22.0)
|
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
|
||||||
atomos (~> 0.1.3)
|
|
||||||
claide (>= 1.0.2, < 2.0)
|
|
||||||
colored2 (~> 3.1)
|
|
||||||
nanaimo (~> 0.3.0)
|
|
||||||
rexml (~> 3.2.4)
|
|
||||||
xcpretty (0.3.0)
|
|
||||||
rouge (~> 2.0.7)
|
|
||||||
xcpretty-travis-formatter (1.0.1)
|
|
||||||
xcpretty (~> 0.2, >= 0.0.7)
|
|
||||||
|
|
||||||
PLATFORMS
|
|
||||||
x86_64-linux
|
|
||||||
|
|
||||||
DEPENDENCIES
|
|
||||||
fastlane
|
|
||||||
|
|
||||||
BUNDLED WITH
|
|
||||||
2.2.8
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400">
|
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/banner.png" width="400" alt="CAPod banner">
|
||||||
|
|
||||||
# Companion App for AirPods (CAPod)
|
# Companion App for AirPods (CAPod)
|
||||||
|
|
||||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||||
|
[](https://shields.rbtlog.dev/eu.darken.capod)
|
||||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||||
[](https://crowdin.com/project/capod)
|
[](https://crowdin.com/project/capod)
|
||||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||||
@@ -17,7 +18,6 @@ A companion app that adds support for AirPod specific features to Android:
|
|||||||
* Ear detection with automatic play/pause.
|
* Ear detection with automatic play/pause.
|
||||||
* Automatically connect phone & AirPods.
|
* Automatically connect phone & AirPods.
|
||||||
* Show popup when case is opened.
|
* Show popup when case is opened.
|
||||||
* Support for Wear OS
|
|
||||||
* Widgets
|
* Widgets
|
||||||
|
|
||||||
CAPod is ad-free. Some additional features require an in-app purchase.
|
CAPod is ad-free. Some additional features require an in-app purchase.
|
||||||
@@ -32,12 +32,22 @@ Currently supported models:
|
|||||||
* AirPods Pro 1. Generation
|
* AirPods Pro 1. Generation
|
||||||
* AirPods Pro 2. Generation
|
* AirPods Pro 2. Generation
|
||||||
* AirPods Pro 2. Generation (USB-C)
|
* AirPods Pro 2. Generation (USB-C)
|
||||||
|
* AirPods Pro 3. Generation
|
||||||
* AirPods Max
|
* AirPods Max
|
||||||
|
* AirPods Max (USB-C)
|
||||||
|
* AirPods Max 2. Generation
|
||||||
* Power Beats Pro
|
* Power Beats Pro
|
||||||
|
* Power Beats Pro 2
|
||||||
* Power Beats 3
|
* Power Beats 3
|
||||||
* Power Beats 4
|
* Power Beats 4
|
||||||
* Beats Solo 3
|
* Beats Solo 3
|
||||||
|
* Beats Solo Pro
|
||||||
|
* Beats Solo 4
|
||||||
|
* Beats Solo Buds
|
||||||
* Beats Studio 3
|
* Beats Studio 3
|
||||||
|
* Beats Studio Buds
|
||||||
|
* Beats Studio Buds+
|
||||||
|
* Beats Studio Pro
|
||||||
* Beats X
|
* Beats X
|
||||||
* Beats Flex
|
* Beats Flex
|
||||||
* Beats Fit Pro
|
* Beats Fit Pro
|
||||||
@@ -51,11 +61,12 @@ Currently supported models:
|
|||||||
|
|
||||||
| Source | Status |
|
| Source | Status |
|
||||||
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](https://play.google.com/store/apps/details?id=eu.darken.capod) |
|
| [Google Play](https://play.google.com/store/apps/details?id=eu.darken.capod) | [](https://play.google.com/store/apps/details?id=eu.darken.capod) |
|
||||||
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
| [Google Play Beta](https://play.google.com/apps/testing/eu.darken.capod) | [](https://play.google.com/apps/testing/eu.darken.capod) | |
|
||||||
| [Github Releases](https://github.com/d4rken-org/capod/releases) | [](https://github.com/d4rken-org/capod/releases/latest) |
|
| [Github (Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
||||||
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
| [Github (Pre-Release)](https://github.com/d4rken-org/capod/releases) | )  |
|
||||||
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
| [F-Droid](https://f-droid.org/en/packages/eu.darken.capod/) | [](https://f-droid.org/en/packages/eu.darken.capod/) |
|
||||||
|
| [F-Droid (IzzyOnDroid)](https://apt.izzysoft.de/packages/eu.darken.capod/) | [](https://apt.izzysoft.de/packages/eu.darken.capod/) |
|
||||||
|
|
||||||
## Support the project
|
## Support the project
|
||||||
|
|
||||||
@@ -70,8 +81,7 @@ Currently supported models:
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
|
||||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
|
||||||
|
|
||||||
## Thanks to
|
## Thanks to
|
||||||
|
|
||||||
@@ -85,6 +95,7 @@ Currently supported models:
|
|||||||
check it out.
|
check it out.
|
||||||
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
* [@kavishdevar](https://github.com/kavishdevar/librepods) and
|
||||||
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
|
his [LibrePods project](https://github.com/kavishdevar/librepods) for sharing a lot of cool stuff.
|
||||||
|
* [Crowdin](https://crowdin.com/) for supporting open-source projects.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,9 @@
|
|||||||
theme: minima
|
theme: minima
|
||||||
plugins:
|
plugins:
|
||||||
- jekyll-relative-links
|
- jekyll-relative-links
|
||||||
|
- jekyll-github-metadata
|
||||||
|
- jemoji
|
||||||
|
|
||||||
relative_links:
|
relative_links:
|
||||||
enabled: true
|
enabled: true
|
||||||
collections: true
|
collections: true
|
||||||
@@ -11,13 +14,12 @@ author: "by Matthias Urhahn"
|
|||||||
|
|
||||||
include:
|
include:
|
||||||
- PRIVACY_POLICY.md
|
- PRIVACY_POLICY.md
|
||||||
|
- README.md
|
||||||
|
- CHANGELOG.md
|
||||||
exclude:
|
exclude:
|
||||||
- buildSrc
|
- buildSrc
|
||||||
- gradle/wrapper
|
- gradle/wrapper
|
||||||
- fastlane
|
- fastlane
|
||||||
- Gemfile
|
|
||||||
- Gemfile.lock
|
|
||||||
- crowdin*
|
- crowdin*
|
||||||
- app
|
- app
|
||||||
- app-common
|
- CONTRIBUTING.md
|
||||||
- app-wear
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/build
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.library")
|
|
||||||
id("kotlin-android")
|
|
||||||
id("com.google.devtools.ksp")
|
|
||||||
id("kotlin-kapt")
|
|
||||||
id("kotlin-parcelize")
|
|
||||||
}
|
|
||||||
apply(plugin = "dagger.hilt.android.plugin")
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk = ProjectConfig.compileSdk
|
|
||||||
namespace = "${ProjectConfig.packageName}.common"
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdk = ProjectConfig.minSdk
|
|
||||||
targetSdk = ProjectConfig.targetSdk
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
consumerProguardFiles("consumer-rules.pro")
|
|
||||||
|
|
||||||
buildConfigField("Long", "VERSION_CODE", "${ProjectConfig.Version.code}L")
|
|
||||||
buildConfigField("String", "VERSION_NAME", "\"${ProjectConfig.Version.name}\"")
|
|
||||||
buildConfigField("String", "APPLICATION_ID", "\"${ProjectConfig.packageName}\"")
|
|
||||||
buildConfigField("String", "GITSHA", "\"${lastCommitHash()}\"")
|
|
||||||
buildConfigField("String", "BUILDTIME", "\"${buildTime()}\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
isCoreLibraryDesugaringEnabled = true
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
freeCompilerArgs = freeCompilerArgs + listOf(
|
|
||||||
"-opt-in=kotlin.ExperimentalStdlibApi",
|
|
||||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
|
||||||
"-opt-in=kotlin.time.ExperimentalTime",
|
|
||||||
"-opt-in=kotlin.ExperimentalUnsignedTypes",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
flavorDimensions.add("version")
|
|
||||||
productFlavors {
|
|
||||||
create("foss") {
|
|
||||||
dimension = "version"
|
|
||||||
}
|
|
||||||
create("gplay") {
|
|
||||||
dimension = "version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
val customProguardRules = fileTree(File("../proguard")) {
|
|
||||||
include("*.pro")
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
proguardFiles(*customProguardRules.toList().toTypedArray())
|
|
||||||
proguardFiles("proguard-rules-debug.pro")
|
|
||||||
}
|
|
||||||
create("beta") {
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
proguardFiles(*customProguardRules.toList().toTypedArray())
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
|
||||||
proguardFiles(*customProguardRules.toList().toTypedArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
unitTests {
|
|
||||||
isIncludeAndroidResources = true
|
|
||||||
}
|
|
||||||
tasks.withType<Test> {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
|
||||||
|
|
||||||
addBaseAndroid()
|
|
||||||
addBaseAndroidUi()
|
|
||||||
addBaseKotlin()
|
|
||||||
addDagger()
|
|
||||||
addMoshi()
|
|
||||||
addBaseWorkManager()
|
|
||||||
addNavigation()
|
|
||||||
|
|
||||||
addTesting()
|
|
||||||
|
|
||||||
}
|
|
||||||
Vendored
-21
@@ -1,21 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest>
|
|
||||||
|
|
||||||
<application>
|
|
||||||
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
package="eu.darken.capod.common">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
|
||||||
|
|
||||||
<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.ACCESS_COARSE_LOCATION"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission
|
|
||||||
android:name="android.permission.BLUETOOTH_SCAN"
|
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
|
||||||
|
|
||||||
<application>
|
|
||||||
<receiver
|
|
||||||
android:name=".bluetooth.BleScanResultReceiver"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="eu.darken.capod.bluetooth.DELIVER_SCAN_RESULTS" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package eu.darken.capod.common
|
|
||||||
|
|
||||||
|
|
||||||
// Can't be const because that prevents them from being mocked in tests
|
|
||||||
@Suppress("MayBeConstant")
|
|
||||||
object BuildConfigWrap {
|
|
||||||
val DEBUG: Boolean = BuildConfig.DEBUG
|
|
||||||
|
|
||||||
val BUILD_TYPE: BuildType = when (val typ = BuildConfig.BUILD_TYPE) {
|
|
||||||
"debug" -> BuildType.DEV
|
|
||||||
"beta" -> BuildType.BETA
|
|
||||||
"release" -> BuildType.RELEASE
|
|
||||||
else -> throw IllegalArgumentException("Unknown buildtype: $typ")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class BuildType {
|
|
||||||
DEV,
|
|
||||||
BETA,
|
|
||||||
RELEASE,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
val FLAVOR: Flavor = when (val flav = BuildConfig.FLAVOR) {
|
|
||||||
"gplay" -> Flavor.GPLAY
|
|
||||||
"foss" -> Flavor.FOSS
|
|
||||||
else -> throw IllegalStateException("Unknown flavor: $flav")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class Flavor {
|
|
||||||
GPLAY,
|
|
||||||
FOSS,
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
val APPLICATION_ID: String = BuildConfig.APPLICATION_ID
|
|
||||||
|
|
||||||
val VERSION_CODE: Long = BuildConfig.VERSION_CODE.toLong()
|
|
||||||
val VERSION_NAME: String = BuildConfig.VERSION_NAME
|
|
||||||
val GIT_SHA: String = BuildConfig.GITSHA
|
|
||||||
val BUILDTIME: String = BuildConfig.BUILDTIME
|
|
||||||
|
|
||||||
val VERSION_DESCRIPTION_LONG: String = "v$VERSION_NAME ($VERSION_CODE) [$GIT_SHA] ${FLAVOR}_$BUILD_TYPE"
|
|
||||||
val VERSION_DESCRIPTION_SHORT: String = "v$VERSION_NAME [$GIT_SHA] $FLAVOR"
|
|
||||||
val VERSION_DESCRIPTION_TINY: String = "v$VERSION_NAME"
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package eu.darken.capod.common
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.res.TypedArray
|
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.annotation.ColorRes
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
|
|
||||||
|
|
||||||
@ColorInt
|
|
||||||
fun Context.getColorForAttr(@AttrRes attrId: Int): Int {
|
|
||||||
var typedArray: TypedArray? = null
|
|
||||||
try {
|
|
||||||
typedArray = this.theme.obtainStyledAttributes(intArrayOf(attrId))
|
|
||||||
return typedArray.getColor(0, 0)
|
|
||||||
} finally {
|
|
||||||
typedArray?.recycle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ColorInt
|
|
||||||
fun Fragment.getColorForAttr(@AttrRes attrId: Int): Int = requireContext().getColorForAttr(attrId)
|
|
||||||
|
|
||||||
@ColorInt
|
|
||||||
fun Context.getCompatColor(@ColorRes attrId: Int): Int {
|
|
||||||
return ContextCompat.getColor(this, attrId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ColorInt
|
|
||||||
fun Fragment.getCompatColor(@ColorRes attrId: Int): Int = requireContext().getCompatColor(attrId)
|
|
||||||
|
|
||||||
@SuppressLint("NewApi")
|
|
||||||
fun Context.startServiceCompat(intent: Intent): ComponentName? {
|
|
||||||
return if (hasApiLevel(26)) startForegroundService(intent) else startService(intent)
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package eu.darken.capod.common
|
|
||||||
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class MediaControl @Inject constructor(
|
|
||||||
private val audioManager: AudioManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val isPlaying: Boolean
|
|
||||||
get() = audioManager.isMusicActive
|
|
||||||
|
|
||||||
suspend fun sendPlay() {
|
|
||||||
log(TAG, INFO) { "sendPlay()" }
|
|
||||||
if (audioManager.isMusicActive) {
|
|
||||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendPause() {
|
|
||||||
log(TAG, INFO) { "sendPause()" }
|
|
||||||
if (!audioManager.isMusicActive) {
|
|
||||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendPlayPause() {
|
|
||||||
log(TAG) { "sendPlayPause()" }
|
|
||||||
if (audioManager.isMusicActive) {
|
|
||||||
sendPause()
|
|
||||||
} else {
|
|
||||||
sendPlay()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun sendKey(keyCode: Int) {
|
|
||||||
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
|
|
||||||
val eventTime = SystemClock.uptimeMillis()
|
|
||||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
|
||||||
delay(100)
|
|
||||||
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("MediaControl")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eu.darken.capod.common
|
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
|
|
||||||
object SystemClockWrap {
|
|
||||||
|
|
||||||
val elapsedRealtimeNanos: Long
|
|
||||||
get() = SystemClock.elapsedRealtimeNanos()
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class BluetoothDevice2(
|
|
||||||
internal val internal: BluetoothDevice,
|
|
||||||
val seenFirstAt: Instant,
|
|
||||||
) {
|
|
||||||
val address: BluetoothAddress
|
|
||||||
get() = internal.address
|
|
||||||
|
|
||||||
val name: String?
|
|
||||||
get() = internal.name
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothHeadset
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothProfile
|
|
||||||
import android.bluetooth.le.BluetoothLeScanner
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.HandlerThread
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
|
||||||
import eu.darken.capod.common.debug.Bugs
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import java.io.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class BluetoothManager2 @Inject constructor(
|
|
||||||
private val manager: BluetoothManager,
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val dispatcherProvider: DispatcherProvider,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val adapter: BluetoothAdapter?
|
|
||||||
get() = manager.adapter
|
|
||||||
|
|
||||||
val scanner: BluetoothLeScanner?
|
|
||||||
get() = adapter?.bluetoothLeScanner
|
|
||||||
|
|
||||||
val isBluetoothEnabled: Flow<Boolean> = callbackFlow {
|
|
||||||
send(manager.adapter?.isEnabled ?: false)
|
|
||||||
|
|
||||||
val receiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (BluetoothAdapter.ACTION_STATE_CHANGED != intent.action) {
|
|
||||||
log(TAG) { "Unknown BluetoothAdapter action: $intent" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val value = when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
|
|
||||||
BluetoothAdapter.STATE_OFF -> false
|
|
||||||
BluetoothAdapter.STATE_ON -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
trySend(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
|
||||||
awaitClose { context.unregisterReceiver(receiver) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBluetoothProfile(profile: Int = BluetoothProfile.HEADSET): Flow<BluetoothProfile2> = callbackFlow {
|
|
||||||
log(TAG, VERBOSE) { "getBluetoothProfile(profile=$profile)" }
|
|
||||||
|
|
||||||
var profileProxy: BluetoothProfile2? = null
|
|
||||||
manager.adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
|
||||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
|
||||||
log(TAG, VERBOSE) { "onServiceConnected(profile=$profile, proxy=$proxy)" }
|
|
||||||
|
|
||||||
profileProxy = BluetoothProfile2(
|
|
||||||
profileType = profile,
|
|
||||||
profileProxy = proxy,
|
|
||||||
).also { trySend(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(profile: Int) {
|
|
||||||
log(TAG, WARN) { "onServiceDisconnected(profile=$profile)" }
|
|
||||||
close(IOException("BluetoothProfile service disconnected (profile=$profile)"))
|
|
||||||
}
|
|
||||||
|
|
||||||
}, profile)
|
|
||||||
|
|
||||||
awaitClose {
|
|
||||||
log(TAG) { "Closing BluetoothProfile: $profileProxy" }
|
|
||||||
profileProxy?.let {
|
|
||||||
manager.adapter.closeProfileProxy(it.profileType, it.proxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun monitorDevicesForProfile(
|
|
||||||
profile: Int = BluetoothProfile.HEADSET
|
|
||||||
): Flow<Set<BluetoothDevice>> = getBluetoothProfile(profile).flatMapLatest { bluetoothProfile ->
|
|
||||||
callbackFlow {
|
|
||||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) starting" }
|
|
||||||
trySend(bluetoothProfile.connectedDevices)
|
|
||||||
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
|
|
||||||
addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
val handlerThread = HandlerThread("BluetoothEventReceiver").apply {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
val handler = Handler(handlerThread.looper)
|
|
||||||
|
|
||||||
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
log(TAG, VERBOSE) { "Bluetooth event (intent=$intent, extras=${intent.extras})" }
|
|
||||||
val action = intent.action
|
|
||||||
if (action == null) {
|
|
||||||
log(TAG, ERROR) { "Bluetooth event without action, how did we get this?" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val device = intent.getParcelableExtra<BluetoothDevice?>(BluetoothDevice.EXTRA_DEVICE)
|
|
||||||
if (device == null) {
|
|
||||||
log(TAG, ERROR) { "Connection event is missing EXTRA_DEVICE: ${intent.extras}" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this@callbackFlow.launch {
|
|
||||||
val currentDevices = bluetoothProfile.connectedDevices
|
|
||||||
|
|
||||||
when (action) {
|
|
||||||
BluetoothDevice.ACTION_ACL_CONNECTED -> {
|
|
||||||
log(TAG) { "Adding $device to current devices $currentDevices" }
|
|
||||||
trySend(currentDevices.plus(device))
|
|
||||||
}
|
|
||||||
|
|
||||||
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
|
|
||||||
log(TAG) { "Removing $device from current devices $currentDevices" }
|
|
||||||
trySend(currentDevices.minus(device))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.registerReceiver(receiver, filter, null, handler)
|
|
||||||
|
|
||||||
awaitClose {
|
|
||||||
log(TAG, VERBOSE) { "connectedDevices(profile=$profile) closed." }
|
|
||||||
context.unregisterReceiver(receiver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val seenDevicesLock = Mutex()
|
|
||||||
private val seenDevicesCache = mutableMapOf<String, Instant>()
|
|
||||||
|
|
||||||
fun connectedDevices(
|
|
||||||
featureFilter: Set<ParcelUuid> = ContinuityProtocol.BLE_FEATURE_UUIDS
|
|
||||||
): Flow<List<BluetoothDevice2>> = isBluetoothEnabled
|
|
||||||
.flatMapLatest { monitorDevicesForProfile(BluetoothProfile.HEADSET) }
|
|
||||||
.map { devices ->
|
|
||||||
val currentAddresses = devices.map { it.address }
|
|
||||||
|
|
||||||
seenDevicesLock.withLock {
|
|
||||||
val cleanedCache = seenDevicesCache.filterKeys { currentAddresses.contains(it) }
|
|
||||||
seenDevicesCache.clear()
|
|
||||||
seenDevicesCache.putAll(cleanedCache)
|
|
||||||
}
|
|
||||||
|
|
||||||
devices
|
|
||||||
.filter { device -> featureFilter.any { feature -> device.hasFeature(feature) } }
|
|
||||||
.map { device ->
|
|
||||||
BluetoothDevice2(
|
|
||||||
internal = device,
|
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
|
||||||
seenDevicesCache[device.address] ?: Instant.now().also {
|
|
||||||
seenDevicesCache[device.address] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bondedDevices(): Flow<Set<BluetoothDevice2>> = flow {
|
|
||||||
val rawDevices = adapter?.bondedDevices ?: throw IllegalStateException("Bluetooth adapter unavailable")
|
|
||||||
val wrappedDevices = rawDevices.map { device ->
|
|
||||||
|
|
||||||
BluetoothDevice2(
|
|
||||||
internal = device,
|
|
||||||
seenFirstAt = seenDevicesLock.withLock {
|
|
||||||
seenDevicesCache[device.address] ?: Instant.now().also {
|
|
||||||
seenDevicesCache[device.address] = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
}.toSet()
|
|
||||||
emit(wrappedDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
|
||||||
try {
|
|
||||||
log(TAG) { "Nudging Android connection to $device" }
|
|
||||||
|
|
||||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
|
||||||
"connect", BluetoothDevice::class.java
|
|
||||||
).apply { isAccessible = true }
|
|
||||||
|
|
||||||
connectMethod.invoke(bluetoothProfile.proxy, device.internal)
|
|
||||||
|
|
||||||
log(TAG) { "Nudged connection to $device" }
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}.first()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Bluetooth", "Manager2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import dagger.Reusable
|
|
||||||
import eu.darken.capod.common.SystemClockWrap
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.fromHex
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class FakeBleData @Inject constructor(
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
|
||||||
if (!debugSettings.showFakeData.value) return originals
|
|
||||||
return originals + getFakeData()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFakeData(): Collection<BleScanResult> {
|
|
||||||
val fakeDevices = mutableListOf<BleScanResult>()
|
|
||||||
// AirPods Gen1
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "78:73:AF:B4:85:22",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen2
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "78:73:FF:B4:85:5E",
|
|
||||||
rssi = Random.nextInt(100) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AirPods Gen3
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "4E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// AirPods Max
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "7E:E5:C7:65:D2:B5",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
// BeatsFlex
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "5E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tws i99999
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "5E:9E:D1:29:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown Device
|
|
||||||
BleScanResult(
|
|
||||||
receivedAt = Instant.now(),
|
|
||||||
address = "6E:9E:D1:49:D2:6D",
|
|
||||||
rssi = Random.nextInt(15, 75) * -1,
|
|
||||||
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
|
|
||||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
|
||||||
).run {
|
|
||||||
fakeDevices.add(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fakeDevices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package eu.darken.capod.common.bluetooth
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.common.R
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class ScannerMode(
|
|
||||||
val identifier: String,
|
|
||||||
@StringRes val labelRes: Int
|
|
||||||
) {
|
|
||||||
@Json(name = "scanner.mode.lowpower") LOW_POWER(
|
|
||||||
"scanner.mode.lowpower",
|
|
||||||
R.string.settings_scanner_mode_lowpower_label
|
|
||||||
),
|
|
||||||
@Json(name = "scanner.mode.balanced") BALANCED(
|
|
||||||
"scanner.mode.balanced",
|
|
||||||
R.string.settings_scanner_mode_balanced_label
|
|
||||||
),
|
|
||||||
@Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
|
|
||||||
"scanner.mode.lowlatency",
|
|
||||||
R.string.settings_scanner_mode_lowlatency_label
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package eu.darken.capod.common.debug
|
|
||||||
|
|
||||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.*
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
object Bugs {
|
|
||||||
var reporter: AutomaticBugReporter? = null
|
|
||||||
fun report(
|
|
||||||
tag: String,
|
|
||||||
message: String,
|
|
||||||
exception: Throwable
|
|
||||||
) {
|
|
||||||
log(TAG, VERBOSE) { "Reporting $exception" }
|
|
||||||
log(tag, ERROR) { "$message\n${exception.asLog()}" }
|
|
||||||
|
|
||||||
reporter?.notify(exception) ?: run {
|
|
||||||
log(TAG, WARN) { "Bug tracking not initialized yet." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val TAG = logTag("Bugs")
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package eu.darken.capod.common.debug
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceDataStore
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.BuildConfigWrap
|
|
||||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
|
||||||
import eu.darken.capod.common.preferences.Settings
|
|
||||||
import eu.darken.capod.common.preferences.createFlowPreference
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class DebugSettings @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : Settings() {
|
|
||||||
|
|
||||||
override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val isAutoReportingEnabled = preferences.createFlowPreference(
|
|
||||||
key = "debug.bugreport.automatic.enabled",
|
|
||||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
|
||||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
|
||||||
)
|
|
||||||
val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false)
|
|
||||||
|
|
||||||
val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false)
|
|
||||||
|
|
||||||
val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false)
|
|
||||||
|
|
||||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
|
||||||
isDebugModeEnabled,
|
|
||||||
showFakeData,
|
|
||||||
showUnfiltered,
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package eu.darken.capod.common.error
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
|
|
||||||
fun Throwable.asErrorDialogBuilder(
|
|
||||||
context: Context
|
|
||||||
) = MaterialAlertDialogBuilder(context).apply {
|
|
||||||
val error = this@asErrorDialogBuilder
|
|
||||||
val localizedError = error.localized(context)
|
|
||||||
|
|
||||||
setTitle(localizedError.label)
|
|
||||||
setMessage(localizedError.description)
|
|
||||||
|
|
||||||
setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.annotation.*
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.darken.capod.common.getColorForAttr
|
|
||||||
|
|
||||||
abstract class BaseAdapter<T : BaseAdapter.VH> : RecyclerView.Adapter<T>() {
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
|
|
||||||
return onCreateBaseVH(parent, viewType)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun onCreateBaseVH(parent: ViewGroup, viewType: Int): T
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
final override fun onBindViewHolder(holder: T, position: Int) {
|
|
||||||
onBindBaseVH(holder, position, mutableListOf())
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
final override fun onBindViewHolder(holder: T, position: Int, payloads: MutableList<Any>) {
|
|
||||||
onBindBaseVH(holder, position, payloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun onBindBaseVH(holder: T, position: Int, payloads: MutableList<Any> = mutableListOf())
|
|
||||||
|
|
||||||
abstract class VH(@LayoutRes layoutRes: Int, private val parent: ViewGroup) : RecyclerView.ViewHolder(
|
|
||||||
LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
|
|
||||||
) {
|
|
||||||
|
|
||||||
val context: Context
|
|
||||||
get() = parent.context
|
|
||||||
|
|
||||||
val resources: Resources
|
|
||||||
get() = context.resources
|
|
||||||
|
|
||||||
val layoutInflater: LayoutInflater
|
|
||||||
get() = LayoutInflater.from(context)
|
|
||||||
|
|
||||||
fun getColor(@ColorRes colorRes: Int): Int = ContextCompat.getColor(context, colorRes)
|
|
||||||
|
|
||||||
fun getColorForAttr(@AttrRes attrRes: Int): Int = context.getColorForAttr(attrRes)
|
|
||||||
|
|
||||||
fun getString(@StringRes stringRes: Int, vararg args: Any): String = context.getString(stringRes, *args)
|
|
||||||
|
|
||||||
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int, vararg args: Any): String =
|
|
||||||
context.resources.getQuantityString(pluralRes, quantity, *args)
|
|
||||||
|
|
||||||
fun getQuantityString(@PluralsRes pluralRes: Int, quantity: Int): String =
|
|
||||||
context.resources.getQuantityString(pluralRes, quantity, quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists
|
|
||||||
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
|
|
||||||
interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
|
|
||||||
|
|
||||||
val viewBinding: Lazy<ViewBindingT>
|
|
||||||
|
|
||||||
val onBindData: ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit
|
|
||||||
|
|
||||||
fun bind(item: ItemT, payloads: MutableList<Any> = mutableListOf()) = with(viewBinding.value) {
|
|
||||||
onBindData(item, payloads)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
inline fun <reified ItemT, ViewBindingT : ViewBinding> BindableVH<ItemT, ViewBindingT>.binding(
|
|
||||||
payload: Boolean = true,
|
|
||||||
crossinline block: ViewBindingT.(ItemT) -> Unit,
|
|
||||||
): ViewBindingT.(item: ItemT, payloads: List<Any>) -> Unit = { item: ItemT, payloads: List<Any> ->
|
|
||||||
val newestItem = when (payload) {
|
|
||||||
true -> payloads.filterIsInstance<ItemT>().lastOrNull() ?: item
|
|
||||||
false -> item
|
|
||||||
}
|
|
||||||
block(newestItem)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
|
|
||||||
interface DataAdapter<T> {
|
|
||||||
val data: MutableList<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <X, T> X.update(newData: List<T>?, notify: Boolean = true) where X : DataAdapter<T>, X : RecyclerView.Adapter<*> {
|
|
||||||
data.clear()
|
|
||||||
if (newData != null) data.addAll(newData)
|
|
||||||
if (notify) notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists
|
|
||||||
|
|
||||||
interface ListItem
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
|
|
||||||
fun RecyclerView.setupDefaults(adapter: RecyclerView.Adapter<*>? = null, dividers: Boolean = true) = apply {
|
|
||||||
layoutManager = LinearLayoutManager(context)
|
|
||||||
itemAnimator = DefaultItemAnimator()
|
|
||||||
if (dividers) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
|
||||||
if (adapter != null) this.adapter = adapter
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.differ
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
import eu.darken.capod.common.lists.modular.mods.StableIdMod
|
|
||||||
|
|
||||||
class AsyncDiffer<A, T : DifferItem> internal constructor(
|
|
||||||
adapter: A,
|
|
||||||
compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
|
|
||||||
compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
|
|
||||||
determinePayload: (T, T) -> Any? = { i1, i2 ->
|
|
||||||
when {
|
|
||||||
i1::class == i2::class -> i1.payloadProvider?.invoke(i1, i2)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) where A : HasAsyncDiffer<T>, A : ModularAdapter<*> {
|
|
||||||
private val callback = object : DiffUtil.ItemCallback<T>() {
|
|
||||||
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
|
|
||||||
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
|
|
||||||
override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val internalList = mutableListOf<T>()
|
|
||||||
private val listDiffer = AsyncListDiffer(adapter, callback)
|
|
||||||
|
|
||||||
val currentList: List<T>
|
|
||||||
get() = synchronized(internalList) { internalList }
|
|
||||||
|
|
||||||
init {
|
|
||||||
adapter.modules.add(0, StableIdMod(currentList))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submitUpdate(newData: List<T>) {
|
|
||||||
listDiffer.submitList(newData) {
|
|
||||||
synchronized(internalList) {
|
|
||||||
internalList.clear()
|
|
||||||
internalList.addAll(newData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.differ
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
|
|
||||||
fun <X, T> X.update(newData: List<T>?)
|
|
||||||
where X : HasAsyncDiffer<T>, X : RecyclerView.Adapter<*> {
|
|
||||||
|
|
||||||
asyncDiffer.submitUpdate(newData ?: emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <A, T : DifferItem> A.setupDiffer(): AsyncDiffer<A, T>
|
|
||||||
where A : HasAsyncDiffer<T>, A : ModularAdapter<*> =
|
|
||||||
AsyncDiffer(this)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.differ
|
|
||||||
|
|
||||||
import eu.darken.capod.common.lists.ListItem
|
|
||||||
|
|
||||||
interface DifferItem : ListItem {
|
|
||||||
val stableId: Long
|
|
||||||
|
|
||||||
val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
|
|
||||||
get() = null
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.differ
|
|
||||||
|
|
||||||
interface HasAsyncDiffer<T : DifferItem> {
|
|
||||||
|
|
||||||
val data: List<T>
|
|
||||||
get() = asyncDiffer.currentList
|
|
||||||
|
|
||||||
val asyncDiffer: AsyncDiffer<*, T>
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.annotation.CallSuper
|
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.darken.capod.common.lists.BaseAdapter
|
|
||||||
|
|
||||||
abstract class ModularAdapter<VH : ModularAdapter.VH> : BaseAdapter<VH>() {
|
|
||||||
val modules = mutableListOf<Module>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
modules.filterIsInstance<Module.Setup>().forEach { it.onAdapterReady(this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemId(position: Int): Long {
|
|
||||||
modules.filterIsInstance<Module.ItemId>().forEach {
|
|
||||||
val id = it.getItemId(this, position)
|
|
||||||
if (id != null) return id
|
|
||||||
}
|
|
||||||
return super.getItemId(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
|
||||||
modules.filterIsInstance<Module.Typing>().forEach {
|
|
||||||
val type = it.onGetItemType(this, position)
|
|
||||||
if (type != null) return type
|
|
||||||
}
|
|
||||||
return super.getItemViewType(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): VH {
|
|
||||||
modules.filterIsInstance<Module.Creator<VH>>().forEach {
|
|
||||||
val vh = it.onCreateModularVH(this, parent, viewType)
|
|
||||||
if (vh != null) return vh
|
|
||||||
}
|
|
||||||
throw IllegalStateException("Couldn't create VH for type $viewType with $parent")
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
override fun onBindBaseVH(holder: VH, position: Int, payloads: MutableList<Any>) {
|
|
||||||
modules.filterIsInstance<Module.Binder<VH>>().forEach {
|
|
||||||
it.onBindModularVH(this, holder, position, payloads)
|
|
||||||
it.onPostBind(this, holder, position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
|
||||||
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onAttachedToRecyclerView(recyclerView) }
|
|
||||||
super.onAttachedToRecyclerView(recyclerView)
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
|
||||||
modules.filterIsInstance<Module.RecyclerViewLifecycle>().forEach { it.onDetachedFromRecyclerView(recyclerView) }
|
|
||||||
super.onDetachedFromRecyclerView(recyclerView)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class VH(@LayoutRes layoutRes: Int, parent: ViewGroup) : BaseAdapter.VH(layoutRes, parent)
|
|
||||||
|
|
||||||
interface Module {
|
|
||||||
interface Setup {
|
|
||||||
fun onAdapterReady(adapter: ModularAdapter<*>)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Creator<T : VH> : Module {
|
|
||||||
fun onCreateModularVH(adapter: ModularAdapter<T>, parent: ViewGroup, viewType: Int): T?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Binder<T : VH> : Module {
|
|
||||||
fun onBindModularVH(adapter: ModularAdapter<T>, vh: T, pos: Int, payloads: MutableList<Any>) {
|
|
||||||
// NOOP
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPostBind(adapter: ModularAdapter<T>, vh: T, pos: Int) {
|
|
||||||
// NOOP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Typing : Module {
|
|
||||||
fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ItemId : Module {
|
|
||||||
fun getItemId(adapter: ModularAdapter<*>, position: Int): Long?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecyclerViewLifecycle : Module {
|
|
||||||
fun onDetachedFromRecyclerView(recyclerView: RecyclerView)
|
|
||||||
fun onAttachedToRecyclerView(recyclerView: RecyclerView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular.mods
|
|
||||||
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
class ClickMod<VHT : ModularAdapter.VH> constructor(
|
|
||||||
private val listener: (VHT, Int) -> Unit
|
|
||||||
) : ModularAdapter.Module.Binder<VHT> {
|
|
||||||
|
|
||||||
override fun onBindModularVH(adapter: ModularAdapter<VHT>, vh: VHT, pos: Int, payloads: MutableList<Any>) {
|
|
||||||
vh.itemView.setOnClickListener { listener.invoke(vh, pos) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular.mods
|
|
||||||
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import eu.darken.capod.common.lists.BindableVH
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
class DataBinderMod<ItemT, HolderT> constructor(
|
|
||||||
private val data: List<ItemT>,
|
|
||||||
private val customBinder: (
|
|
||||||
(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payload: MutableList<Any>) -> Unit
|
|
||||||
)? = null
|
|
||||||
) : ModularAdapter.Module.Binder<HolderT> where HolderT : BindableVH<ItemT, ViewBinding>, HolderT : ModularAdapter.VH {
|
|
||||||
|
|
||||||
override fun onBindModularVH(adapter: ModularAdapter<HolderT>, vh: HolderT, pos: Int, payloads: MutableList<Any>) {
|
|
||||||
customBinder?.invoke(adapter, vh, pos, mutableListOf()) ?: vh.bind(data[pos], payloads)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular.mods
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
class SimpleVHCreatorMod<HolderT> constructor(
|
|
||||||
private val viewType: Int = 0,
|
|
||||||
private val factory: (ViewGroup) -> HolderT
|
|
||||||
) : ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
|
|
||||||
|
|
||||||
override fun onCreateModularVH(adapter: ModularAdapter<HolderT>, parent: ViewGroup, viewType: Int): HolderT? {
|
|
||||||
if (this.viewType != viewType) return null
|
|
||||||
return factory.invoke(parent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular.mods
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import eu.darken.capod.common.lists.differ.DifferItem
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
class StableIdMod<ItemT : DifferItem> constructor(
|
|
||||||
private val data: List<ItemT>,
|
|
||||||
private val customResolver: (position: Int) -> Long = {
|
|
||||||
(data[it] as? DifferItem)?.stableId ?: RecyclerView.NO_ID
|
|
||||||
}
|
|
||||||
) : ModularAdapter.Module.ItemId, ModularAdapter.Module.Setup {
|
|
||||||
|
|
||||||
override fun onAdapterReady(adapter: ModularAdapter<*>) {
|
|
||||||
adapter.setHasStableIds(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemId(adapter: ModularAdapter<*>, position: Int): Long? {
|
|
||||||
return customResolver.invoke(position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package eu.darken.capod.common.lists.modular.mods
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import eu.darken.capod.common.lists.modular.ModularAdapter
|
|
||||||
|
|
||||||
class TypedVHCreatorMod<HolderT> constructor(
|
|
||||||
private val typeResolver: (Int) -> Boolean,
|
|
||||||
private val factory: (ViewGroup) -> HolderT
|
|
||||||
) : ModularAdapter.Module.Typing,
|
|
||||||
ModularAdapter.Module.Creator<HolderT> where HolderT : ModularAdapter.VH {
|
|
||||||
|
|
||||||
private fun ModularAdapter<*>.determineOurViewType(): Int {
|
|
||||||
val typingModules = modules.filterIsInstance(ModularAdapter.Module.Typing::class.java)
|
|
||||||
return typingModules.indexOf(this@TypedVHCreatorMod)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetItemType(adapter: ModularAdapter<*>, pos: Int): Int? {
|
|
||||||
return if (typeResolver.invoke(pos)) adapter.determineOurViewType() else null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateModularVH(
|
|
||||||
adapter: ModularAdapter<HolderT>,
|
|
||||||
parent: ViewGroup,
|
|
||||||
viewType: Int
|
|
||||||
): HolderT? {
|
|
||||||
if (adapter.determineOurViewType() != viewType) return null
|
|
||||||
return factory.invoke(parent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package eu.darken.capod.common.navigation
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.annotation.IdRes
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.FragmentContainerView
|
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.NavDirections
|
|
||||||
import androidx.navigation.fragment.NavHostFragment
|
|
||||||
import androidx.navigation.fragment.findNavController
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
|
|
||||||
fun Fragment.doNavigate(direction: NavDirections) = findNavController().doNavigate(direction)
|
|
||||||
|
|
||||||
fun Fragment.popBackStack(): Boolean {
|
|
||||||
if (!isAdded) {
|
|
||||||
IllegalStateException("Fragment is not added").also {
|
|
||||||
log(WARN) { "Trying to pop backstack on Fragment that isn't added to an Activity: ${it.asLog()}" }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return findNavController().popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [FragmentContainerView] does not access [NavController] in [Activity.onCreate]
|
|
||||||
* as workaround [FragmentManager] is used to get the [NavController]
|
|
||||||
* @param id [Int] NavFragment id
|
|
||||||
* @see <a href="https://issuetracker.google.com/issues/142847973">issue-142847973</a>
|
|
||||||
*/
|
|
||||||
@Throws(IllegalStateException::class)
|
|
||||||
fun FragmentManager.findNavController(@IdRes id: Int): NavController {
|
|
||||||
val fragment = findFragmentById(id) ?: throw IllegalStateException("Fragment is not found for id:$id")
|
|
||||||
return NavHostFragment.findNavController(fragment)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package eu.darken.capod.common.navigation
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.navigation.NavArgs
|
|
||||||
import androidx.navigation.NavArgsLazy
|
|
||||||
import java.io.Serializable
|
|
||||||
|
|
||||||
// TODO Remove with "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha/stable"
|
|
||||||
inline fun <reified Args : NavArgs> SavedStateHandle.navArgs() = NavArgsLazy(Args::class) {
|
|
||||||
Bundle().apply {
|
|
||||||
keys().forEach {
|
|
||||||
when (val value = get<Any>(it)) {
|
|
||||||
is Serializable -> putSerializable(it, value)
|
|
||||||
is Parcelable -> putParcelable(it, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package eu.darken.capod.common.navigation
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.annotation.IdRes
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.NavDirections
|
|
||||||
|
|
||||||
fun NavController.navigateIfNotThere(@IdRes resId: Int, args: Bundle? = null) {
|
|
||||||
if (currentDestination?.id == resId) return
|
|
||||||
navigate(resId, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.doNavigate(direction: NavDirections) {
|
|
||||||
currentDestination?.getAction(direction.actionId)?.let { navigate(direction) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun NavController.isGraphSet(): Boolean = try {
|
|
||||||
graph
|
|
||||||
true
|
|
||||||
} catch (e: IllegalStateException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eu.darken.capod.common.navigation
|
|
||||||
|
|
||||||
import androidx.annotation.IdRes
|
|
||||||
import androidx.navigation.NavDestination
|
|
||||||
|
|
||||||
fun NavDestination?.hasAction(@IdRes id: Int): Boolean {
|
|
||||||
if (this == null) return false
|
|
||||||
return getAction(id) != null
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
class FlowPreference<T> constructor(
|
|
||||||
private val preferences: SharedPreferences,
|
|
||||||
val key: String,
|
|
||||||
val rawReader: (Any?) -> T,
|
|
||||||
val rawWriter: (T) -> Any?
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val flowInternal = MutableStateFlow(value)
|
|
||||||
val flow: Flow<T> = flowInternal
|
|
||||||
|
|
||||||
private val preferenceChangeListener =
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey ->
|
|
||||||
if (changedKey != key) return@OnSharedPreferenceChangeListener
|
|
||||||
|
|
||||||
val newValue = rawReader(changedPrefs.all[key])
|
|
||||||
|
|
||||||
val currentValue = flowInternal.value
|
|
||||||
if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) {
|
|
||||||
log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
var value: T
|
|
||||||
get() = rawReader(valueRaw)
|
|
||||||
set(newVal) {
|
|
||||||
valueRaw = rawWriter(newVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
var valueRaw: Any?
|
|
||||||
get() = preferences.all[key] ?: rawWriter(rawReader(null))
|
|
||||||
set(value) {
|
|
||||||
preferences.edit {
|
|
||||||
when (value) {
|
|
||||||
is Boolean -> putBoolean(key, value)
|
|
||||||
is String -> putString(key, value)
|
|
||||||
is Int -> putInt(key, value)
|
|
||||||
is Long -> putLong(key, value)
|
|
||||||
is Float -> putFloat(key, value)
|
|
||||||
null -> remove(key)
|
|
||||||
else -> throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
flowInternal.value = rawReader(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(update: (T) -> T) {
|
|
||||||
value = update(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
|
|
||||||
|
|
||||||
inline fun <reified T> basicReader(defaultValue: T): (rawValue: Any?) -> T =
|
|
||||||
{ rawValue ->
|
|
||||||
(rawValue ?: defaultValue) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T> basicWriter(): (T) -> Any? =
|
|
||||||
{ value ->
|
|
||||||
when (value) {
|
|
||||||
is Boolean -> value
|
|
||||||
is String -> value
|
|
||||||
is Int -> value
|
|
||||||
is Long -> value
|
|
||||||
is Float -> value
|
|
||||||
null -> null
|
|
||||||
else -> throw NotImplementedError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
|
||||||
key: String,
|
|
||||||
defaultValue: T = null as T
|
|
||||||
) = FlowPreference(
|
|
||||||
preferences = this,
|
|
||||||
key = key,
|
|
||||||
rawReader = basicReader(defaultValue),
|
|
||||||
rawWriter = basicWriter()
|
|
||||||
)
|
|
||||||
|
|
||||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
|
||||||
key: String,
|
|
||||||
noinline reader: (rawValue: Any?) -> T,
|
|
||||||
noinline writer: (value: T) -> Any?
|
|
||||||
) = FlowPreference(
|
|
||||||
preferences = this,
|
|
||||||
key = key,
|
|
||||||
rawReader = reader,
|
|
||||||
rawWriter = writer
|
|
||||||
)
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
|
|
||||||
inline fun <reified T> moshiReader(
|
|
||||||
moshi: Moshi,
|
|
||||||
defaultValue: T,
|
|
||||||
): (Any?) -> T {
|
|
||||||
val adapter = moshi.adapter(T::class.java)
|
|
||||||
return { rawValue ->
|
|
||||||
rawValue as String?
|
|
||||||
rawValue?.let { adapter.fromJson(it) } ?: defaultValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T> moshiWriter(
|
|
||||||
moshi: Moshi,
|
|
||||||
): (T) -> Any? {
|
|
||||||
val adapter = moshi.adapter(T::class.java)
|
|
||||||
return { newValue: T ->
|
|
||||||
newValue?.let { adapter.toJson(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
|
|
||||||
key: String,
|
|
||||||
defaultValue: T = null as T,
|
|
||||||
moshi: Moshi,
|
|
||||||
) = FlowPreference(
|
|
||||||
preferences = this,
|
|
||||||
key = key,
|
|
||||||
rawReader = moshiReader(moshi, defaultValue),
|
|
||||||
rawWriter = moshiWriter(moshi)
|
|
||||||
)
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import androidx.preference.SwitchPreferenceCompat
|
|
||||||
|
|
||||||
class MaterialSwitchPreference(context: Context, attrs: AttributeSet?) :
|
|
||||||
SwitchPreferenceCompat(context, attrs) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Use material switch
|
|
||||||
widgetLayoutResource = eu.darken.capod.common.R.layout.preference_material_switch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import androidx.preference.PreferenceDataStore
|
|
||||||
|
|
||||||
open class PreferenceStoreMapper(
|
|
||||||
private vararg val flowPreferences: FlowPreference<*>
|
|
||||||
) : PreferenceDataStore() {
|
|
||||||
|
|
||||||
override fun getBoolean(key: String, defValue: Boolean): Boolean {
|
|
||||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw as Boolean
|
|
||||||
} ?: throw NotImplementedError("getBoolean(key=$key, defValue=$defValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putBoolean(key: String, value: Boolean) {
|
|
||||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw = value
|
|
||||||
} ?: throw NotImplementedError("putBoolean(key=$key, defValue=$value)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getString(key: String, defValue: String?): String? {
|
|
||||||
val pref = flowPreferences.singleOrNull { it.key == key }
|
|
||||||
?: throw NotImplementedError("getString(key=$key, defValue=$defValue)")
|
|
||||||
|
|
||||||
return pref.let { flowPref ->
|
|
||||||
flowPref.valueRaw as String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putString(key: String, value: String?) {
|
|
||||||
val pref = flowPreferences.singleOrNull { it.key == key }
|
|
||||||
?: throw NotImplementedError("putString(key=$key, defValue=$value)")
|
|
||||||
pref.let { flowPref ->
|
|
||||||
flowPref.valueRaw = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getInt(key: String?, defValue: Int): Int {
|
|
||||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw as Int
|
|
||||||
} ?: throw NotImplementedError("getInt(key=$key, defValue=$defValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putInt(key: String?, value: Int) {
|
|
||||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw = value
|
|
||||||
} ?: throw NotImplementedError("putInt(key=$key, defValue=$value)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLong(key: String?, defValue: Long): Long {
|
|
||||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw as Long
|
|
||||||
} ?: throw NotImplementedError("getLong(key=$key, defValue=$defValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putLong(key: String?, value: Long) {
|
|
||||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw = value
|
|
||||||
} ?: throw NotImplementedError("putLong(key=$key, defValue=$value)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFloat(key: String?, defValue: Float): Float {
|
|
||||||
return flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw as Float
|
|
||||||
} ?: throw NotImplementedError("getFloat(key=$key, defValue=$defValue)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putFloat(key: String?, value: Float) {
|
|
||||||
flowPreferences.singleOrNull { it.key == key }?.let { flowPref ->
|
|
||||||
flowPref.valueRaw = value
|
|
||||||
} ?: throw NotImplementedError("putFloat(key=$key, defValue=$value)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
|
||||||
throw NotImplementedError("putStringSet(key=$key, defValue=$values)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
|
|
||||||
throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceDataStore
|
|
||||||
|
|
||||||
abstract class Settings {
|
|
||||||
|
|
||||||
abstract val preferenceDataStore: PreferenceDataStore
|
|
||||||
|
|
||||||
abstract val preferences: SharedPreferences
|
|
||||||
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
package eu.darken.capod.common.preferences
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
|
|
||||||
fun SharedPreferences.clearAndNotify() {
|
|
||||||
val currentKeys = this.all.keys.toSet()
|
|
||||||
log(VERBOSE) { "$this clearAndNotify(): $currentKeys" }
|
|
||||||
edit {
|
|
||||||
currentKeys.forEach { remove(it) }
|
|
||||||
}
|
|
||||||
// Clear does not notify anyone using registerOnSharedPreferenceChangeListener
|
|
||||||
edit(commit = true) { clear() }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package eu.darken.capod.common.serialization
|
|
||||||
|
|
||||||
import com.squareup.moshi.FromJson
|
|
||||||
import com.squareup.moshi.ToJson
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
class ByteArrayAdapter {
|
|
||||||
@ToJson
|
|
||||||
fun toJson(obj: ByteArray): String = Base64.encode(obj)
|
|
||||||
|
|
||||||
@FromJson
|
|
||||||
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package eu.darken.capod.common.serialization
|
|
||||||
|
|
||||||
import com.squareup.moshi.FromJson
|
|
||||||
import com.squareup.moshi.ToJson
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
class JavaInstantAdapter {
|
|
||||||
@ToJson
|
|
||||||
fun toJson(obj: Instant): Long = obj.toEpochMilli()
|
|
||||||
|
|
||||||
@FromJson
|
|
||||||
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eu.darken.capod.common.serialization
|
|
||||||
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
@Module
|
|
||||||
class SerializationModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun moshi(): Moshi = Moshi.Builder()
|
|
||||||
.add(JavaInstantAdapter())
|
|
||||||
.add(ByteArrayAdapter())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package eu.darken.capod.common.uix
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
|
|
||||||
|
|
||||||
abstract class Fragment2(@LayoutRes val layoutRes: Int?) : Fragment(layoutRes ?: 0) {
|
|
||||||
|
|
||||||
constructor() : this(null)
|
|
||||||
|
|
||||||
internal val tag: String =
|
|
||||||
logTag("Fragment", "${this.javaClass.simpleName}(${Integer.toHexString(hashCode())})")
|
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
|
||||||
log(tag, VERBOSE) { "onAttach(context=$context)" }
|
|
||||||
super.onAttach(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
log(tag, VERBOSE) { "onCreate(savedInstanceState=$savedInstanceState)" }
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
|
||||||
log(tag, VERBOSE) {
|
|
||||||
"onCreateView(inflater=$inflater, container=$container, savedInstanceState=$savedInstanceState"
|
|
||||||
}
|
|
||||||
return layoutRes?.let { inflater.inflate(it, container, false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
log(tag, VERBOSE) { "onViewCreated(view=$view, savedInstanceState=$savedInstanceState)" }
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
|
||||||
val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
v.setPadding(
|
|
||||||
v.paddingLeft,
|
|
||||||
systemWindowInsets.top,
|
|
||||||
v.paddingRight,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
|
||||||
log(tag, VERBOSE) { "onActivityCreated(savedInstanceState=$savedInstanceState)" }
|
|
||||||
super.onActivityCreated(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
log(tag, VERBOSE) { "onResume()" }
|
|
||||||
super.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
log(tag, VERBOSE) { "onPause()" }
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
log(tag, VERBOSE) { "onDestroyView()" }
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetach() {
|
|
||||||
log(tag, VERBOSE) { "onDetach()" }
|
|
||||||
super.onDetach()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
log(tag, VERBOSE) { "onDestroy()" }
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
log(tag, VERBOSE) { "onActivityResult(requestCode=$requestCode, resultCode=$resultCode, data=$data)" }
|
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package eu.darken.capod.common.uix
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.error.asErrorDialogBuilder
|
|
||||||
import eu.darken.capod.common.navigation.doNavigate
|
|
||||||
import eu.darken.capod.common.navigation.popBackStack
|
|
||||||
|
|
||||||
|
|
||||||
abstract class Fragment3(@LayoutRes layoutRes: Int?) : Fragment2(layoutRes) {
|
|
||||||
|
|
||||||
constructor() : this(null)
|
|
||||||
|
|
||||||
abstract val ui: ViewBinding?
|
|
||||||
abstract val vm: ViewModel3
|
|
||||||
|
|
||||||
var onErrorEvent: ((Throwable) -> Boolean)? = null
|
|
||||||
|
|
||||||
var onFinishEvent: (() -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
|
|
||||||
vm.navEvents.observe2(ui) {
|
|
||||||
log { "navEvents: $it" }
|
|
||||||
|
|
||||||
it?.run { doNavigate(this) } ?: onFinishEvent?.invoke() ?: popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
vm.errorEvents.observe2(ui) {
|
|
||||||
val showDialog = onErrorEvent?.invoke(it) ?: true
|
|
||||||
if (showDialog) it.asErrorDialogBuilder(requireContext()).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T> LiveData<T>.observe2(
|
|
||||||
crossinline callback: (T) -> Unit
|
|
||||||
) {
|
|
||||||
observe(viewLifecycleOwner) { callback.invoke(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T, reified VB : ViewBinding?> LiveData<T>.observe2(
|
|
||||||
ui: VB,
|
|
||||||
crossinline callback: VB.(T) -> Unit
|
|
||||||
) {
|
|
||||||
observe(viewLifecycleOwner) { callback.invoke(ui, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package eu.darken.capod.common.uix
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright 2018 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.annotation.MainThread
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.lifecycle.*
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an existing ViewModel or creates a new one in the scope (usually, a fragment or
|
|
||||||
* an activity), associated with this `ViewModelProvider`.
|
|
||||||
*
|
|
||||||
* @see ViewModelProvider.get(Class)
|
|
||||||
*/
|
|
||||||
//@MainThread
|
|
||||||
//inline fun <reified VM : ViewModel> ViewModelProvider.get() = get(VM::class.java)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An implementation of [Lazy] used by [androidx.fragment.app.Fragment.viewModels] and
|
|
||||||
* [androidx.activity.ComponentActivity.viewmodels].
|
|
||||||
*
|
|
||||||
* [storeProducer] is a lambda that will be called during initialization, [VM] will be created
|
|
||||||
* in the scope of returned [ViewModelStore].
|
|
||||||
*
|
|
||||||
* [factoryProducer] is a lambda that will be called during initialization,
|
|
||||||
* returned [ViewModelProvider.Factory] will be used for creation of [VM]
|
|
||||||
*/
|
|
||||||
class ViewModelLazyKeyed<VM : ViewModel>(
|
|
||||||
private val viewModelClass: KClass<VM>,
|
|
||||||
private val keyProducer: (() -> String)? = null,
|
|
||||||
private val storeProducer: () -> ViewModelStore,
|
|
||||||
private val factoryProducer: () -> ViewModelProvider.Factory
|
|
||||||
) : Lazy<VM> {
|
|
||||||
private var cached: VM? = null
|
|
||||||
|
|
||||||
override val value: VM
|
|
||||||
get() {
|
|
||||||
val viewModel = cached
|
|
||||||
return if (viewModel == null) {
|
|
||||||
val factory = factoryProducer()
|
|
||||||
val store = storeProducer()
|
|
||||||
val key = keyProducer?.invoke() ?: "androidx.lifecycle.ViewModelProvider.DefaultKey"
|
|
||||||
ViewModelProvider(store, factory).get(
|
|
||||||
key + ":" + viewModelClass.java.canonicalName,
|
|
||||||
viewModelClass.java
|
|
||||||
).also {
|
|
||||||
cached = it
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isInitialized() = cached != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]:
|
|
||||||
* ```
|
|
||||||
* class MyFragment : Fragment() {
|
|
||||||
* val viewmodel: NYViewModel by viewmodels()
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Custom [ViewModelProvider.Factory] can be defined via [factoryProducer] parameter,
|
|
||||||
* factory returned by it will be used to create [ViewModel]:
|
|
||||||
* ```
|
|
||||||
* class MyFragment : Fragment() {
|
|
||||||
* val viewmodel: MYViewModel by viewmodels { myFactory }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Default scope may be overridden with parameter [ownerProducer]:
|
|
||||||
* ```
|
|
||||||
* class MyFragment : Fragment() {
|
|
||||||
* val viewmodel: MYViewModel by viewmodels ({requireParentFragment()})
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This property can be accessed only after this Fragment is attached i.e., after
|
|
||||||
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
|
|
||||||
*/
|
|
||||||
@MainThread
|
|
||||||
inline fun <reified VM : ViewModel> Fragment.viewModelsKeyed(
|
|
||||||
noinline keyProducer: (() -> String)? = null,
|
|
||||||
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
|
|
||||||
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
|
||||||
) = createViewModelLazyKeyed(VM::class, keyProducer, { ownerProducer().viewModelStore }, factoryProducer)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a property delegate to access parent activity's [ViewModel],
|
|
||||||
* if [factoryProducer] is specified then [ViewModelProvider.Factory]
|
|
||||||
* returned by it will be used to create [ViewModel] first time.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* class MyFragment : Fragment() {
|
|
||||||
* val viewmodel: MyViewModel by activityViewModels()
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This property can be accessed only after this Fragment is attached i.e., after
|
|
||||||
* [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException.
|
|
||||||
*/
|
|
||||||
@MainThread
|
|
||||||
inline fun <reified VM : ViewModel> Fragment.activityViewModelsKeyed(
|
|
||||||
noinline keyProducer: (() -> String)? = null,
|
|
||||||
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
|
||||||
) = createViewModelLazyKeyed(VM::class, keyProducer, { requireActivity().viewModelStore }, factoryProducer)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper method for creation of [ViewModelLazy], that resolves `null` passed as [factoryProducer]
|
|
||||||
* to default factory.
|
|
||||||
*/
|
|
||||||
@MainThread
|
|
||||||
fun <VM : ViewModel> Fragment.createViewModelLazyKeyed(
|
|
||||||
viewModelClass: KClass<VM>,
|
|
||||||
keyProducer: (() -> String)? = null,
|
|
||||||
storeProducer: () -> ViewModelStore,
|
|
||||||
factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
|
||||||
): Lazy<VM> {
|
|
||||||
val factoryPromise = factoryProducer ?: {
|
|
||||||
val application = activity?.application ?: throw IllegalStateException(
|
|
||||||
"ViewModel can be accessed only when Fragment is attached"
|
|
||||||
)
|
|
||||||
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
|
|
||||||
}
|
|
||||||
return ViewModelLazyKeyed(viewModelClass, keyProducer, storeProducer, factoryPromise)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, if [factoryProducer]
|
|
||||||
* is specified then [ViewModelProvider.Factory] returned by it will be used
|
|
||||||
* to create [ViewModel] first time.
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* class MyComponentActivity : ComponentActivity() {
|
|
||||||
* val viewmodel: MyViewModel by viewmodels()
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This property can be accessed only after the Activity is attached to the Application,
|
|
||||||
* and access prior to that will result in IllegalArgumentException.
|
|
||||||
*/
|
|
||||||
@MainThread
|
|
||||||
inline fun <reified VM : ViewModel> ComponentActivity.viewModelsKeyed(
|
|
||||||
noinline keyProducer: (() -> String)? = null,
|
|
||||||
noinline factoryProducer: (() -> ViewModelProvider.Factory)? = null
|
|
||||||
): Lazy<VM> {
|
|
||||||
val factoryPromise = factoryProducer ?: {
|
|
||||||
val application = application ?: throw IllegalArgumentException(
|
|
||||||
"ViewModel can be accessed only when Activity is attached"
|
|
||||||
)
|
|
||||||
ViewModelProvider.AndroidViewModelFactory.getInstance(application)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ViewModelLazyKeyed(VM::class, keyProducer, { viewModelStore }, factoryPromise)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package eu.darken.capod.common.viewbinding
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.view.View
|
|
||||||
import androidx.annotation.MainThread
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import kotlin.properties.ReadOnlyProperty
|
|
||||||
import kotlin.reflect.KProperty
|
|
||||||
|
|
||||||
inline fun <FragmentT : Fragment, reified BindingT : ViewBinding> FragmentT.viewBinding() =
|
|
||||||
this.viewBinding(
|
|
||||||
bindingProvider = {
|
|
||||||
val bindingMethod = BindingT::class.java.getMethod("bind", View::class.java)
|
|
||||||
bindingMethod(null, requireView()) as BindingT
|
|
||||||
},
|
|
||||||
lifecycleOwnerProvider = { viewLifecycleOwner }
|
|
||||||
)
|
|
||||||
|
|
||||||
@Suppress("unused")
|
|
||||||
fun <FragmentT : Fragment, BindingT : ViewBinding> FragmentT.viewBinding(
|
|
||||||
bindingProvider: FragmentT.() -> BindingT,
|
|
||||||
lifecycleOwnerProvider: FragmentT.() -> LifecycleOwner
|
|
||||||
) = ViewBindingProperty(bindingProvider, lifecycleOwnerProvider)
|
|
||||||
|
|
||||||
class ViewBindingProperty<ComponentT : LifecycleOwner, BindingT : ViewBinding>(
|
|
||||||
private val bindingProvider: (ComponentT) -> BindingT,
|
|
||||||
private val lifecycleOwnerProvider: ComponentT.() -> LifecycleOwner
|
|
||||||
) : ReadOnlyProperty<ComponentT, BindingT> {
|
|
||||||
|
|
||||||
private val uiHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var localRef: ComponentT? = null
|
|
||||||
private var viewBinding: BindingT? = null
|
|
||||||
|
|
||||||
private val onDestroyObserver = object : DefaultLifecycleObserver {
|
|
||||||
// Called right before Fragment.onDestroyView
|
|
||||||
override fun onDestroy(owner: LifecycleOwner) {
|
|
||||||
localRef?.lifecycle?.removeObserver(this) ?: return
|
|
||||||
|
|
||||||
localRef = null
|
|
||||||
|
|
||||||
uiHandler.post {
|
|
||||||
log(VERBOSE) { "Resetting viewBinding" }
|
|
||||||
viewBinding = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainThread
|
|
||||||
override fun getValue(thisRef: ComponentT, property: KProperty<*>): BindingT {
|
|
||||||
if (localRef == null && viewBinding != null) {
|
|
||||||
log(WARN) { "Fragment.onDestroyView() was called, but the handler didn't execute our delayed reset." }
|
|
||||||
/**
|
|
||||||
* There is a fragment racecondition if you navigate to another fragment and quickly popBackStack().
|
|
||||||
* Our uiHandler.post { } will not have executed for some reason.
|
|
||||||
* In that case we manually null the old viewBinding, to allow for clean recreation.
|
|
||||||
*/
|
|
||||||
viewBinding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When quickly navigating, a fragment may be created that was never visible to the user.
|
|
||||||
* It's possible that [Fragment.onDestroyView] is called, but [DefaultLifecycleObserver.onDestroy] is not.
|
|
||||||
* This means the ViewBinding will is not be set to `null` and it still holds the previous layout,
|
|
||||||
* instead of the new layout that the Fragment inflated when navigating back to it.
|
|
||||||
*/
|
|
||||||
(localRef as? Fragment)?.view?.let {
|
|
||||||
if (it != viewBinding?.root && localRef === thisRef) {
|
|
||||||
log(WARN) { "Different view for the same fragment, resetting old viewBinding." }
|
|
||||||
viewBinding = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewBinding?.let {
|
|
||||||
// Only accessible from within the same component
|
|
||||||
require(localRef === thisRef)
|
|
||||||
return@getValue it
|
|
||||||
}
|
|
||||||
|
|
||||||
val lifecycle = lifecycleOwnerProvider(thisRef).lifecycle
|
|
||||||
|
|
||||||
return bindingProvider(thisRef).also {
|
|
||||||
viewBinding = it
|
|
||||||
localRef = thisRef
|
|
||||||
lifecycle.addObserver(onDestroyObserver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package eu.darken.capod.main.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceDataStore
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
|
||||||
import eu.darken.capod.common.preferences.Settings
|
|
||||||
import eu.darken.capod.common.preferences.createFlowPreference
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class GeneralSettings @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
debugSettings: DebugSettings,
|
|
||||||
moshi: Moshi,
|
|
||||||
) : Settings() {
|
|
||||||
|
|
||||||
override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi)
|
|
||||||
|
|
||||||
val useExtraMonitorNotification = preferences.createFlowPreference("core.monitor.notification.connected", false)
|
|
||||||
|
|
||||||
val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi)
|
|
||||||
|
|
||||||
val showAll = preferences.createFlowPreference("core.showall.enabled", true)
|
|
||||||
|
|
||||||
val minimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.20f)
|
|
||||||
|
|
||||||
val mainDeviceAddress = preferences.createFlowPreference<BluetoothAddress?>("core.maindevice.address", null)
|
|
||||||
val mainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
|
|
||||||
val mainDeviceIdentityKey = preferences.createFlowPreference<IdentityResolvingKey?>(
|
|
||||||
"core.maindevice.identitykey",
|
|
||||||
null,
|
|
||||||
moshi
|
|
||||||
)
|
|
||||||
val mainDeviceEncryptionKey = preferences.createFlowPreference<ProximityEncryptionKey?>(
|
|
||||||
"core.maindevice.encryptionkey",
|
|
||||||
null,
|
|
||||||
moshi
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOffloadedFilteringDisabled = preferences.createFlowPreference(
|
|
||||||
"core.compat.offloaded.filtering.disabled",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false)
|
|
||||||
val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false)
|
|
||||||
|
|
||||||
val isOnboardingDone = preferences.createFlowPreference("core.onboarding.done", false)
|
|
||||||
|
|
||||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
|
||||||
monitorMode,
|
|
||||||
useExtraMonitorNotification,
|
|
||||||
scannerMode,
|
|
||||||
showAll,
|
|
||||||
minimumSignalQuality,
|
|
||||||
mainDeviceAddress,
|
|
||||||
isOffloadedFilteringDisabled,
|
|
||||||
isOffloadedBatchingDisabled,
|
|
||||||
useIndirectScanResultCallback,
|
|
||||||
debugSettings.isAutoReportingEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package eu.darken.capod.main.core
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.common.R
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class MonitorMode(
|
|
||||||
@StringRes val labelRes: Int
|
|
||||||
) {
|
|
||||||
@Json(name = "monitor.mode.manual") MANUAL(
|
|
||||||
R.string.settings_monitor_mode_manual_label
|
|
||||||
),
|
|
||||||
@Json(name = "monitor.mode.automatic") AUTOMATIC(
|
|
||||||
R.string.settings_monitor_mode_automatic_label
|
|
||||||
),
|
|
||||||
@Json(name = "monitor.mode.always") ALWAYS(
|
|
||||||
R.string.settings_monitor_mode_always_label
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package eu.darken.capod.main.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.permissions.Permission
|
|
||||||
import eu.darken.capod.common.permissions.isRequired
|
|
||||||
import eu.darken.capod.reaction.core.ReactionSettings
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import java.util.*
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PermissionTool @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val generalSettings: GeneralSettings,
|
|
||||||
private val reactionSettings: ReactionSettings,
|
|
||||||
) {
|
|
||||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
|
||||||
|
|
||||||
fun recheck() {
|
|
||||||
log(TAG) { "recheck()" }
|
|
||||||
permissionCheckTrigger.value = UUID.randomUUID()
|
|
||||||
}
|
|
||||||
|
|
||||||
val missingPermissions: Flow<Set<Permission>> = combine(
|
|
||||||
permissionCheckTrigger,
|
|
||||||
generalSettings.monitorMode.flow,
|
|
||||||
reactionSettings.showPopUpOnCaseOpen.flow
|
|
||||||
) { _, monitorMode, showPopUp ->
|
|
||||||
Permission.values()
|
|
||||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
|
|
||||||
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
|
|
||||||
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
|
|
||||||
.filter { it.isRequired(context) }
|
|
||||||
.toSet()
|
|
||||||
}
|
|
||||||
.onEach { log(TAG) { "Missing permission: $it" } }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("PermissionTool")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package eu.darken.capod.monitor.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.adapter
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
@Singleton
|
|
||||||
class PodDeviceCache @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val dispatcherProvider: DispatcherProvider,
|
|
||||||
moshi: Moshi,
|
|
||||||
) {
|
|
||||||
private val cacheDir by lazy {
|
|
||||||
File(context.cacheDir, "device_cache").apply { mkdirs() }
|
|
||||||
}
|
|
||||||
private val mainDeviceCacheFile = File(cacheDir, "main_device.raw")
|
|
||||||
private val jsonAdapter = moshi.adapter<BleScanResult>()
|
|
||||||
private val lock = Mutex()
|
|
||||||
|
|
||||||
suspend fun saveMainDevice(device: BleScanResult?) = withContext(dispatcherProvider.IO) {
|
|
||||||
log(TAG, VERBOSE) { "saveMainDevice(device=$device)" }
|
|
||||||
lock.withLock {
|
|
||||||
try {
|
|
||||||
if (device == null) {
|
|
||||||
mainDeviceCacheFile.delete()
|
|
||||||
} else {
|
|
||||||
val json = jsonAdapter.toJson(device)
|
|
||||||
mainDeviceCacheFile.writeText(json)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "Failed to save $device:${e.asLog()}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loadMainDevice(): BleScanResult? = withContext(dispatcherProvider.IO) {
|
|
||||||
log(TAG, VERBOSE) { "loadMainDevice()" }
|
|
||||||
lock.withLock {
|
|
||||||
if (!mainDeviceCacheFile.exists()) return@withLock null
|
|
||||||
try {
|
|
||||||
val raw = mainDeviceCacheFile.readText()
|
|
||||||
jsonAdapter.fromJson(raw)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, ERROR) { "Failed to read main-device:${e.asLog()}" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Monitor", "PodMonitor", "Cache")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package eu.darken.capod.monitor.core
|
|
||||||
|
|
||||||
import android.bluetooth.le.ScanFilter
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanner
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
|
||||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.flow.replayingShare
|
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|
||||||
import eu.darken.capod.common.flow.throttleLatest
|
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.main.core.PermissionTool
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import eu.darken.capod.pods.core.PodFactory
|
|
||||||
import eu.darken.capod.pods.core.apple.ApplePods
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PodMonitor @Inject constructor(
|
|
||||||
@AppScope private val appScope: CoroutineScope,
|
|
||||||
private val bleScanner: BleScanner,
|
|
||||||
private val podFactory: PodFactory,
|
|
||||||
private val generalSettings: GeneralSettings,
|
|
||||||
bluetoothManager: BluetoothManager2,
|
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
private val podDeviceCache: PodDeviceCache,
|
|
||||||
permissionTool: PermissionTool,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
|
|
||||||
private val cacheLock = Mutex()
|
|
||||||
|
|
||||||
val devices: Flow<List<PodDevice>> = combine(
|
|
||||||
permissionTool.missingPermissions,
|
|
||||||
bluetoothManager.isBluetoothEnabled
|
|
||||||
) { missingPermissions, isBluetoothEnabled ->
|
|
||||||
log(TAG) { "devices: missingPermissions=$missingPermissions, isBluetoothEnabled=$isBluetoothEnabled" }
|
|
||||||
// We just want to retrigger if permissions change.
|
|
||||||
isBluetoothEnabled
|
|
||||||
}
|
|
||||||
.flatMapLatest { isReady ->
|
|
||||||
if (!isReady) {
|
|
||||||
log(TAG, WARN) { "Bluetooth is not ready" }
|
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
createBleScanner()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { newPods ->
|
|
||||||
val pods = processWithCache(newPods)
|
|
||||||
|
|
||||||
val presorted = sortPodsToInterest(pods.values)
|
|
||||||
val main = determineMainDevice(presorted)
|
|
||||||
newPods?.firstOrNull { it.device.identifier == main?.identifier }?.let {
|
|
||||||
podDeviceCache.saveMainDevice(it.scanResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
presorted.sortedByDescending { it == main }
|
|
||||||
}
|
|
||||||
.retryWhen { cause, attempt ->
|
|
||||||
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
|
|
||||||
delay(3000)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
.onStart { emit(emptyList()) }
|
|
||||||
.replayingShare(appScope)
|
|
||||||
|
|
||||||
val mainDevice: Flow<PodDevice?> = devices
|
|
||||||
.map { determineMainDevice(it) }
|
|
||||||
.setupCommonEventHandlers(TAG) { "mainDevice" }
|
|
||||||
.replayingShare(appScope)
|
|
||||||
|
|
||||||
private data class ScannerOptions(
|
|
||||||
val scannerMode: ScannerMode,
|
|
||||||
val showUnfiltered: Boolean,
|
|
||||||
val offloadedFilteringDisabled: Boolean,
|
|
||||||
val offloadedBatchingDisabled: Boolean,
|
|
||||||
val disableDirectCallback: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun createBleScanner() = combine(
|
|
||||||
generalSettings.scannerMode.flow,
|
|
||||||
debugSettings.showUnfiltered.flow,
|
|
||||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
|
||||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
|
||||||
generalSettings.useIndirectScanResultCallback.flow,
|
|
||||||
) {
|
|
||||||
scannermode,
|
|
||||||
showUnfiltered,
|
|
||||||
isOffloadedBatchingDisabled,
|
|
||||||
isOffloadedFilteringDisabled,
|
|
||||||
useIndirectScanResultCallback,
|
|
||||||
->
|
|
||||||
ScannerOptions(
|
|
||||||
scannerMode = scannermode,
|
|
||||||
showUnfiltered = showUnfiltered,
|
|
||||||
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
|
|
||||||
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
|
|
||||||
disableDirectCallback = useIndirectScanResultCallback,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.throttleLatest(1000)
|
|
||||||
.flatMapLatest { options ->
|
|
||||||
val filters = when {
|
|
||||||
options.showUnfiltered -> {
|
|
||||||
log(TAG, WARN) { "Using unfiltered scan mode" }
|
|
||||||
setOf(ScanFilter.Builder().build())
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> ProximityPairing.getBleScanFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
bleScanner.scan(
|
|
||||||
filters = filters,
|
|
||||||
scannerMode = options.scannerMode,
|
|
||||||
disableOffloadFiltering = options.offloadedFilteringDisabled,
|
|
||||||
disableOffloadBatching = options.offloadedBatchingDisabled,
|
|
||||||
disableDirectScanCallback = options.disableDirectCallback,
|
|
||||||
).map { preFilterAndMap(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun processWithCache(
|
|
||||||
newPods: List<PodFactory.Result>?
|
|
||||||
): Map<PodDevice.Id, PodDevice> = cacheLock.withLock {
|
|
||||||
if (newPods == null) {
|
|
||||||
log(TAG) { "Null result, Bluetooth is disabled." }
|
|
||||||
deviceCache.clear()
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = Instant.now()
|
|
||||||
deviceCache.toList().forEach { (key, value) ->
|
|
||||||
if (Duration.between(value.seenLastAt, now) > Duration.ofSeconds(20)) {
|
|
||||||
log(TAG, VERBOSE) { "Removing stale device from cache: $value" }
|
|
||||||
deviceCache.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
|
|
||||||
|
|
||||||
pods.putAll(deviceCache)
|
|
||||||
|
|
||||||
newPods.map { it.device }.forEach {
|
|
||||||
deviceCache[it.identifier] = it
|
|
||||||
pods[it.identifier] = it
|
|
||||||
}
|
|
||||||
return pods
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun preFilterAndMap(rawResults: Collection<BleScanResult>): List<PodFactory.Result> = rawResults
|
|
||||||
.groupBy { it.address }
|
|
||||||
.values
|
|
||||||
.map { sameAdrDevs ->
|
|
||||||
// For each address we only want the newest result, upstream may batch data
|
|
||||||
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
|
|
||||||
sameAdrDevs.minus(newest).let {
|
|
||||||
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
|
|
||||||
}
|
|
||||||
newest
|
|
||||||
}
|
|
||||||
.mapNotNull { podFactory.createPod(it) }
|
|
||||||
|
|
||||||
private fun sortPodsToInterest(pods: Collection<PodDevice>): List<PodDevice> {
|
|
||||||
val now = Instant.now()
|
|
||||||
|
|
||||||
return pods.sortedWith(
|
|
||||||
compareByDescending<PodDevice> { true }
|
|
||||||
.thenBy {
|
|
||||||
val age = Duration.between(it.seenLastAt, now).seconds
|
|
||||||
if (age < 5) 0L else (age / 3L)
|
|
||||||
}
|
|
||||||
.thenByDescending { it.signalQuality }
|
|
||||||
.thenByDescending { (it.seenCounter / 10) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun determineMainDevice(pods: List<PodDevice>): PodDevice? {
|
|
||||||
val irkHit = pods.filterIsInstance<ApplePods>().firstOrNull { it.flags.isIRKMatch }
|
|
||||||
if (irkHit != null) {
|
|
||||||
log(TAG) { "Main device determined via IRK: $irkHit" }
|
|
||||||
return irkHit
|
|
||||||
}
|
|
||||||
|
|
||||||
val mainDeviceModel = generalSettings.mainDeviceModel.value
|
|
||||||
val presorted = sortPodsToInterest(pods).sortedByDescending {
|
|
||||||
it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
return presorted.firstOrNull()?.let { candidate ->
|
|
||||||
when {
|
|
||||||
candidate.model == PodDevice.Model.UNKNOWN -> null
|
|
||||||
mainDeviceModel != PodDevice.Model.UNKNOWN && candidate.model != mainDeviceModel -> null
|
|
||||||
candidate.signalQuality <= generalSettings.minimumSignalQuality.value -> null
|
|
||||||
else -> candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun latestMainDevice(): PodDevice? {
|
|
||||||
val currentMain = mainDevice.firstOrNull()
|
|
||||||
log(TAG) { "Live mainDevice is $currentMain" }
|
|
||||||
|
|
||||||
return currentMain ?: podDeviceCache.loadMainDevice()
|
|
||||||
?.let { podFactory.createPod(it)?.device }
|
|
||||||
.also { log(TAG) { "Cached mainDevice is $it" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Monitor", "PodMonitor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package eu.darken.capod.monitor.core
|
|
||||||
|
|
||||||
import dagger.Reusable
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@Reusable
|
|
||||||
class PodSorter @Inject constructor(
|
|
||||||
|
|
||||||
)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import com.squareup.moshi.Json
|
|
||||||
import com.squareup.moshi.JsonClass
|
|
||||||
import eu.darken.capod.common.R
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
interface PodDevice {
|
|
||||||
|
|
||||||
val identifier: Id
|
|
||||||
|
|
||||||
val model: Model
|
|
||||||
|
|
||||||
val address: BluetoothAddress
|
|
||||||
get() = scanResult.address
|
|
||||||
|
|
||||||
val seenLastAt: Instant
|
|
||||||
|
|
||||||
val seenFirstAt: Instant
|
|
||||||
|
|
||||||
val seenCounter: Int
|
|
||||||
|
|
||||||
val scanResult: BleScanResult
|
|
||||||
|
|
||||||
val rssi: Int
|
|
||||||
get() = scanResult.rssi
|
|
||||||
|
|
||||||
val reliability: Float
|
|
||||||
|
|
||||||
val signalQuality: Float
|
|
||||||
get() {
|
|
||||||
/**
|
|
||||||
* This is not correct but it works ¯\_(ツ)_/¯
|
|
||||||
* The range of the RSSI is device specific (ROMs).
|
|
||||||
*/
|
|
||||||
val sqRssi = ((100 - abs(rssi)) / 100f)
|
|
||||||
val sqReliability = max(BASE_CONFIDENCE, reliability)
|
|
||||||
val sqAge = (Duration.between(seenFirstAt, Instant.now()).toMinutes().coerceAtMost(60) / 60f) * 0.25f
|
|
||||||
log(VERBOSE) { "Signal Quality ($address): rssi=$sqRssi, reliability=$reliability, age=$sqAge" }
|
|
||||||
return (sqRssi + sqReliability + sqAge) / 2f
|
|
||||||
}
|
|
||||||
|
|
||||||
val rawData: Map<Int, ByteArray>
|
|
||||||
get() = scanResult.manufacturerSpecificData
|
|
||||||
|
|
||||||
val rawDataHex: List<String>
|
|
||||||
get() = rawData.entries.map { entry ->
|
|
||||||
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLabel(context: Context): String = model.label
|
|
||||||
|
|
||||||
@get:DrawableRes
|
|
||||||
val iconRes: Int
|
|
||||||
get() = model.iconRes
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class Id(private val id: UUID = UUID.randomUUID())
|
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
|
||||||
enum class Model(
|
|
||||||
val label: String,
|
|
||||||
@DrawableRes val iconRes: Int = R.drawable.devic_earbuds_generic_both,
|
|
||||||
) {
|
|
||||||
@Json(name = "airpods.gen1") AIRPODS_GEN1(
|
|
||||||
label = "AirPods (Gen 1)",
|
|
||||||
iconRes = R.drawable.devic_airpods_gen1_both,
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.gen2") AIRPODS_GEN2(
|
|
||||||
"AirPods (Gen 2)",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.gen3") AIRPODS_GEN3(
|
|
||||||
"AirPods (Gen 3)",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.gen4") AIRPODS_GEN4(
|
|
||||||
"AirPods (Gen 4)",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
|
||||||
"AirPods (Gen 4 ANC)",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.pro") AIRPODS_PRO(
|
|
||||||
"AirPods Pro",
|
|
||||||
R.drawable.devic_airpods_pro2_both
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.pro2") AIRPODS_PRO2(
|
|
||||||
"AirPods Pro 2",
|
|
||||||
R.drawable.devic_airpods_pro2_both
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
|
||||||
"AirPods Pro 2 USB-C",
|
|
||||||
R.drawable.devic_airpods_pro2_both
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.max") AIRPODS_MAX(
|
|
||||||
"AirPods Max",
|
|
||||||
R.drawable.devic_headphones_generic
|
|
||||||
),
|
|
||||||
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
|
|
||||||
"AirPods Max USB-C",
|
|
||||||
R.drawable.devic_headphones_generic
|
|
||||||
),
|
|
||||||
@Json(name = "beats.flex") BEATS_FLEX(
|
|
||||||
"Beats Flex"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.solo.3") BEATS_SOLO_3(
|
|
||||||
"Beats Solo 3"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.studio.3") BEATS_STUDIO_3(
|
|
||||||
"Beats Studio 3"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.x") BEATS_X(
|
|
||||||
"Beats X"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
|
|
||||||
"Power Beats 3"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.powerbeats.4") POWERBEATS_4(
|
|
||||||
"Power Beats 4"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
|
|
||||||
"Power Beats Pro"
|
|
||||||
),
|
|
||||||
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
|
|
||||||
"Beats Fit Pro"
|
|
||||||
),
|
|
||||||
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
|
|
||||||
"AirPods (Gen 1)? \uD83C\uDFAD",
|
|
||||||
R.drawable.devic_airpods_gen1_both,
|
|
||||||
),
|
|
||||||
@Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
|
|
||||||
"AirPods (Gen 2)? \uD83C\uDFAD",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
|
|
||||||
"AirPods (Gen 3)? \uD83C\uDFAD",
|
|
||||||
R.drawable.devic_airpods_gen2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
|
|
||||||
"AirPods Pro? \uD83C\uDFAD",
|
|
||||||
R.drawable.devic_airpods_pro2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
|
|
||||||
"AirPods Pro2? \uD83C\uDFAD",
|
|
||||||
R.drawable.devic_airpods_pro2_both,
|
|
||||||
),
|
|
||||||
@Json(name = "unknown") UNKNOWN(
|
|
||||||
"Unknown"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val BASE_CONFIDENCE = 0.0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.icu.text.RelativeDateTimeFormatter
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import eu.darken.capod.common.R
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
fun DualPodDevice.getBatteryLevelLeftPod(context: Context): String =
|
|
||||||
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
|
||||||
|
|
||||||
fun DualPodDevice.getBatteryLevelRightPod(context: Context): String =
|
|
||||||
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
|
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
|
||||||
|
|
||||||
fun HasCase.getBatteryLevelCase(context: Context): String =
|
|
||||||
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
|
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
|
||||||
|
|
||||||
fun SinglePodDevice.getBatteryLevelHeadset(context: Context): String =
|
|
||||||
batteryHeadsetPercent?.let { "${(it * 100).roundToInt()}%" }
|
|
||||||
?: context.getString(R.string.general_value_not_available_label)
|
|
||||||
|
|
||||||
fun PodDevice.getSignalQuality(context: Context): String {
|
|
||||||
val multiplier = 100 * signalQuality
|
|
||||||
return "${multiplier.roundToInt()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
@DrawableRes
|
|
||||||
fun getBatteryDrawable(percent: Float?): Int = when {
|
|
||||||
percent == null -> R.drawable.ic_baseline_battery_unknown_24
|
|
||||||
percent > 0.95f -> R.drawable.ic_baseline_battery_full_24
|
|
||||||
percent > 0.80f -> R.drawable.ic_baseline_battery_6_bar_24
|
|
||||||
percent > 0.65f -> R.drawable.ic_baseline_battery_5_bar_24
|
|
||||||
percent > 0.50f -> R.drawable.ic_baseline_battery_4_bar_24
|
|
||||||
percent > 0.35f -> R.drawable.ic_baseline_battery_3_bar_24
|
|
||||||
percent > 0.20f -> R.drawable.ic_baseline_battery_2_bar_24
|
|
||||||
percent > 0.05f -> R.drawable.ic_baseline_battery_1_bar_24
|
|
||||||
else -> R.drawable.ic_baseline_battery_0_bar_24
|
|
||||||
}
|
|
||||||
|
|
||||||
private val lastSeenFormatter = RelativeDateTimeFormatter.getInstance()
|
|
||||||
|
|
||||||
fun PodDevice.lastSeenFormatted(now: Instant): String {
|
|
||||||
val duration = Duration.between(seenLastAt, now)
|
|
||||||
return if (duration > Duration.ofMinutes(1)) {
|
|
||||||
lastSeenFormatter.format(
|
|
||||||
duration.toMinutes().toDouble(),
|
|
||||||
RelativeDateTimeFormatter.Direction.LAST,
|
|
||||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
lastSeenFormatter.format(
|
|
||||||
duration.seconds.toDouble(),
|
|
||||||
RelativeDateTimeFormatter.Direction.LAST,
|
|
||||||
RelativeDateTimeFormatter.RelativeUnit.SECONDS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PodDevice.firstSeenFormatted(now: Instant): String {
|
|
||||||
val duration = Duration.between(seenFirstAt, now)
|
|
||||||
return lastSeenFormatter.format(
|
|
||||||
duration.toMinutes().toDouble(),
|
|
||||||
RelativeDateTimeFormatter.Direction.LAST,
|
|
||||||
RelativeDateTimeFormatter.RelativeUnit.MINUTES
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core
|
|
||||||
|
|
||||||
interface SinglePodDevice : PodDevice {
|
|
||||||
|
|
||||||
val batteryHeadsetPercent: Float?
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.apple
|
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AppleFactory @Inject constructor(
|
|
||||||
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
|
|
||||||
private val proximityPairingDecoder: ProximityPairing.Decoder,
|
|
||||||
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
|
|
||||||
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
|
|
||||||
private val unknownAppleFactory: UnknownAppleDevice.Factory,
|
|
||||||
private val generalSettings: GeneralSettings,
|
|
||||||
private val rpaChecker: RPAChecker,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val lock = Mutex()
|
|
||||||
|
|
||||||
private fun getMessage(scanResult: BleScanResult): ProximityMessage? {
|
|
||||||
val messages = try {
|
|
||||||
continuityProtocolDecoder.decode(scanResult)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log(TAG, WARN) { "Data wasn't continuity protocol conform:\n${e.asLog()}" }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (messages.isEmpty()) {
|
|
||||||
log(TAG, WARN) { "Data contained no continuity messages: $scanResult" }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.size > 1) {
|
|
||||||
log(TAG, WARN) { "Decoded multiple continuity messages, picking first: $messages" }
|
|
||||||
}
|
|
||||||
|
|
||||||
val proximityMessage = proximityPairingDecoder.decode(messages.first())
|
|
||||||
if (proximityMessage == null) {
|
|
||||||
log(TAG) { "Not a proximity pairing message: $messages" }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return proximityMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
|
|
||||||
val proximityMessage = getMessage(scanResult) ?: return@withLock null
|
|
||||||
|
|
||||||
val isIrkMatch = generalSettings.mainDeviceIdentityKey.value
|
|
||||||
?.let { rpaChecker.verify(scanResult.address, it) }
|
|
||||||
?: false
|
|
||||||
|
|
||||||
val payload = ProximityPayload(
|
|
||||||
public = ProximityPayload.Public(
|
|
||||||
proximityMessage.data.take(9).toUByteArray()
|
|
||||||
),
|
|
||||||
private = run {
|
|
||||||
if (!isIrkMatch) return@run null
|
|
||||||
if (proximityMessage.data.size != ProximityPairing.PAIRING_MESSAGE_LENGTH) return@run null
|
|
||||||
|
|
||||||
val encKey = generalSettings.mainDeviceEncryptionKey.value
|
|
||||||
if (encKey == null) return@run null
|
|
||||||
|
|
||||||
val encrypted = proximityMessage.data.takeLast(16).toUByteArray().toByteArray()
|
|
||||||
proximityMessageDecrypter.decrypt(encrypted, encKey)?.let {
|
|
||||||
ProximityPayload.Private(data = it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) }
|
|
||||||
|
|
||||||
|
|
||||||
return@withLock (factory ?: unknownAppleFactory).create(
|
|
||||||
scanResult = scanResult,
|
|
||||||
payload = payload,
|
|
||||||
flags = ApplePods.Flags(
|
|
||||||
isIRKMatch = isIrkMatch,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Pod", "Apple", "Factory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.apple
|
|
||||||
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen1
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen2
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen3
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsGen4Anc
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMax
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsMaxUsbc
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2
|
|
||||||
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro2Usbc
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.BeatsFitPro
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.BeatsFlex
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.BeatsSolo3
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.BeatsStudio3
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.BeatsX
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats3
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.PowerBeats4
|
|
||||||
import eu.darken.capod.pods.core.apple.beats.PowerBeatsPro
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen1
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen2
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsGen3
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro
|
|
||||||
import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
|
|
||||||
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
@Module
|
|
||||||
abstract class AppleFactoryModule {
|
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
|
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
|
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
|
|
||||||
|
|
||||||
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
|
|
||||||
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.apple
|
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.pods.core.apple.history.KnownDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
|
|
||||||
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
|
|
||||||
|
|
||||||
interface ApplePodsFactory {
|
|
||||||
fun isResponsible(message: ProximityMessage): Boolean
|
|
||||||
fun create(scanResult: BleScanResult, payload: ProximityPayload, flags: ApplePods.Flags): ApplePods
|
|
||||||
|
|
||||||
data class ModelInfo(
|
|
||||||
val full: UShort,
|
|
||||||
val dirty: UByte,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
|
|
||||||
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
|
|
||||||
dirty = data[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
|
|
||||||
|
|
||||||
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
|
||||||
val definitive = setOf(
|
|
||||||
DualApplePods.LidState.OPEN,
|
|
||||||
DualApplePods.LidState.CLOSED,
|
|
||||||
DualApplePods.LidState.NOT_IN_CASE,
|
|
||||||
)
|
|
||||||
if (definitive.contains(basic.caseLidState)) return basic.caseLidState
|
|
||||||
|
|
||||||
return history
|
|
||||||
.takeLast(2)
|
|
||||||
.filterIsInstance<DualApplePods>()
|
|
||||||
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
|
||||||
?.caseLidState
|
|
||||||
?: DualApplePods.LidState.NOT_IN_CASE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.apple.protocol
|
|
||||||
|
|
||||||
typealias IdentityResolvingKey = ByteArray
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.apple.protocol
|
|
||||||
|
|
||||||
typealias ProximityEncryptionKey = ByteArray
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package eu.darken.capod.pods.core.unknown
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.darken.capod.common.R
|
|
||||||
import eu.darken.capod.common.bluetooth.BleScanResult
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
data class UnknownDevice(
|
|
||||||
override val identifier: PodDevice.Id = PodDevice.Id(),
|
|
||||||
override val seenLastAt: Instant = Instant.now(),
|
|
||||||
override val seenFirstAt: Instant = Instant.now(),
|
|
||||||
override val seenCounter: Int = 1,
|
|
||||||
override val scanResult: BleScanResult,
|
|
||||||
override val reliability: Float = 0f,
|
|
||||||
private val rssiAverage: Int? = null,
|
|
||||||
) : PodDevice {
|
|
||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
|
|
||||||
|
|
||||||
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
|
|
||||||
|
|
||||||
override val rssi: Int
|
|
||||||
get() = rssiAverage ?: super.rssi
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("PodDevice", "Unknown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.core
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceDataStore
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import eu.darken.capod.common.preferences.PreferenceStoreMapper
|
|
||||||
import eu.darken.capod.common.preferences.Settings
|
|
||||||
import eu.darken.capod.common.preferences.createFlowPreference
|
|
||||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ReactionSettings @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
moshi: Moshi,
|
|
||||||
) : Settings() {
|
|
||||||
|
|
||||||
override val preferences: SharedPreferences =
|
|
||||||
context.getSharedPreferences("settings_reaction", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val autoPause = preferences.createFlowPreference(
|
|
||||||
"reaction.autopause.enabled",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val autoPlay = preferences.createFlowPreference(
|
|
||||||
"reaction.autoplay.enabled",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val autoConnect = preferences.createFlowPreference(
|
|
||||||
"reaction.autoconnect.enabled",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val autoConnectCondition = preferences.createFlowPreference(
|
|
||||||
"reaction.autoconnect.condition",
|
|
||||||
AutoConnectCondition.WHEN_SEEN,
|
|
||||||
moshi
|
|
||||||
)
|
|
||||||
|
|
||||||
val showPopUpOnCaseOpen = preferences.createFlowPreference(
|
|
||||||
"reaction.popup.caseopen",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val showPopUpOnConnection = preferences.createFlowPreference(
|
|
||||||
"reaction.popup.connected",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val onePodMode = preferences.createFlowPreference(
|
|
||||||
"reaction.onepod.enabled",
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper(
|
|
||||||
autoPause,
|
|
||||||
autoPlay,
|
|
||||||
autoConnect,
|
|
||||||
autoConnectCondition,
|
|
||||||
showPopUpOnCaseOpen,
|
|
||||||
showPopUpOnConnection,
|
|
||||||
onePodMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.core.autoconnect
|
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
|
||||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
|
||||||
import eu.darken.capod.reaction.core.ReactionSettings
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AutoConnect @Inject constructor(
|
|
||||||
private val bluetoothManager: BluetoothManager2,
|
|
||||||
private val podMonitor: PodMonitor,
|
|
||||||
private val generalSettings: GeneralSettings,
|
|
||||||
private val reactionSettings: ReactionSettings
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = reactionSettings.autoConnect.flow
|
|
||||||
.flatMapLatest { isAutoConnectEnabled ->
|
|
||||||
if (isAutoConnectEnabled) {
|
|
||||||
combine(
|
|
||||||
bluetoothManager.connectedDevices().distinctUntilChanged(),
|
|
||||||
podMonitor.mainDevice.filterNotNull().distinctUntilChangedBy { it.rawDataHex },
|
|
||||||
) { connectedDevices, mainDevice ->
|
|
||||||
connectedDevices to mainDevice
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptyFlow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { (connectedDevices, mainDevice) ->
|
|
||||||
log(TAG, VERBOSE) { "mainPodDevice is $mainDevice" }
|
|
||||||
|
|
||||||
val mainDeviceAddr = generalSettings.mainDeviceAddress.value
|
|
||||||
if (mainDeviceAddr.isNullOrEmpty()) {
|
|
||||||
log(TAG, WARN) { "mainDeviceAddress is null" }
|
|
||||||
return@map
|
|
||||||
}
|
|
||||||
|
|
||||||
val bondedDevice = bluetoothManager.bondedDevices().first().firstOrNull { it.address == mainDeviceAddr }
|
|
||||||
|
|
||||||
if (bondedDevice == null) {
|
|
||||||
log(TAG, WARN) { "No bonded device matches $mainDeviceAddr" }
|
|
||||||
return@map
|
|
||||||
} else {
|
|
||||||
log(TAG, VERBOSE) { "Found target device: $bondedDevice" }
|
|
||||||
}
|
|
||||||
|
|
||||||
val isAlreadyConnected = connectedDevices.any {
|
|
||||||
it.address == bondedDevice.address
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAlreadyConnected) {
|
|
||||||
log(TAG) { "We are already connected to the target device: $bondedDevice" }
|
|
||||||
return@map
|
|
||||||
}
|
|
||||||
|
|
||||||
val condition = reactionSettings.autoConnectCondition.value
|
|
||||||
log(TAG) { "Checking condition $condition" }
|
|
||||||
val conditionFulfilled = when (condition) {
|
|
||||||
AutoConnectCondition.WHEN_SEEN -> true
|
|
||||||
AutoConnectCondition.CASE_OPEN -> when (mainDevice) {
|
|
||||||
is DualApplePods -> mainDevice.caseLidState == DualApplePods.LidState.OPEN
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
AutoConnectCondition.IN_EAR -> when (mainDevice) {
|
|
||||||
is HasEarDetection -> {
|
|
||||||
if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.value) {
|
|
||||||
mainDevice.isEitherPodInEar
|
|
||||||
} else {
|
|
||||||
mainDevice.isBeingWorn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!conditionFulfilled) {
|
|
||||||
log(TAG) { "Auto connect condition ($condition) is not fullfilled." }
|
|
||||||
return@map
|
|
||||||
}
|
|
||||||
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
|
||||||
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
|
||||||
}
|
|
||||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Reaction", "AutoConnect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.core.playpause
|
|
||||||
|
|
||||||
import eu.darken.capod.common.MediaControl
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|
||||||
import eu.darken.capod.common.flow.withPrevious
|
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
|
||||||
import eu.darken.capod.pods.core.HasEarDetection
|
|
||||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
|
||||||
import eu.darken.capod.reaction.core.ReactionSettings
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PlayPause @Inject constructor(
|
|
||||||
private val podMonitor: PodMonitor,
|
|
||||||
private val bluetoothManager: BluetoothManager2,
|
|
||||||
private val reactionSettings: ReactionSettings,
|
|
||||||
private val mediaControl: MediaControl,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun monitor() = combine(
|
|
||||||
reactionSettings.autoPlay.flow,
|
|
||||||
reactionSettings.autoPause.flow,
|
|
||||||
reactionSettings.onePodMode.flow,
|
|
||||||
) { play, pause, _ -> play || pause }
|
|
||||||
.flatMapLatest { if (it) bluetoothManager.connectedDevices() else emptyFlow() }
|
|
||||||
.flatMapLatest {
|
|
||||||
if (it.isEmpty()) {
|
|
||||||
log(TAG) { "No known devices connected." }
|
|
||||||
emptyFlow()
|
|
||||||
} else {
|
|
||||||
log(TAG) { "Known devices connected: $it" }
|
|
||||||
podMonitor.mainDevice
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.withPrevious()
|
|
||||||
.filter { (previous, current) ->
|
|
||||||
if (previous == null || current == null) return@filter false
|
|
||||||
log(TAG, VERBOSE) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
|
|
||||||
val match = previous.identifier == current.identifier
|
|
||||||
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
|
|
||||||
match
|
|
||||||
}
|
|
||||||
.onEach { (previous, current) ->
|
|
||||||
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
|
|
||||||
|
|
||||||
val previousWorn: Boolean?
|
|
||||||
val currentWorn: Boolean?
|
|
||||||
|
|
||||||
when {
|
|
||||||
reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
|
|
||||||
previousWorn = previous.isEitherPodInEar
|
|
||||||
currentWorn = current.isEitherPodInEar
|
|
||||||
log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
|
|
||||||
log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
|
|
||||||
}
|
|
||||||
previous is HasEarDetection && current is HasEarDetection -> {
|
|
||||||
previousWorn = previous.isBeingWorn
|
|
||||||
currentWorn = current.isBeingWorn
|
|
||||||
log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
log(TAG, VERBOSE) { "Current devices don't support ear detection." }
|
|
||||||
previousWorn = null
|
|
||||||
currentWorn = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
|
|
||||||
if (reactionSettings.autoPlay.value) {
|
|
||||||
log(TAG) { "autoPlay is triggered, sendPlay()" }
|
|
||||||
mediaControl.sendPlay()
|
|
||||||
} else {
|
|
||||||
log(TAG, VERBOSE) { "autoPlay is disabled" }
|
|
||||||
}
|
|
||||||
} else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) {
|
|
||||||
if (reactionSettings.autoPause.value) {
|
|
||||||
log(TAG) { "autoPause is triggered, sendPause()" }
|
|
||||||
mediaControl.sendPause()
|
|
||||||
} else {
|
|
||||||
log(TAG) { "autoPause is disabled" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Reaction", "PlayPause")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package eu.darken.capod.reaction.core.popup
|
|
||||||
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
|
||||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
|
||||||
import eu.darken.capod.common.debug.logging.log
|
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
|
||||||
import eu.darken.capod.common.flow.withPrevious
|
|
||||||
import eu.darken.capod.main.core.GeneralSettings
|
|
||||||
import eu.darken.capod.monitor.core.PodMonitor
|
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
|
||||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
|
||||||
import eu.darken.capod.reaction.core.ReactionSettings
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.merge
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PopUpReaction @Inject constructor(
|
|
||||||
private val podMonitor: PodMonitor,
|
|
||||||
private val reactionSettings: ReactionSettings,
|
|
||||||
private val generalSettings: GeneralSettings,
|
|
||||||
private val bluetoothManager: BluetoothManager2,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val caseCoolDowns = mutableMapOf<PodDevice.Id, Instant>()
|
|
||||||
|
|
||||||
private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
|
|
||||||
.flatMapLatest { isEnabled ->
|
|
||||||
if (isEnabled) {
|
|
||||||
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex }
|
|
||||||
} else {
|
|
||||||
emptyFlow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.withPrevious()
|
|
||||||
.setupCommonEventHandlers(TAG) { "popUpCase" }
|
|
||||||
.mapNotNull { (previous, current) ->
|
|
||||||
if (previous !is DualApplePods? || current !is DualApplePods) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
log(TAG, VERBOSE) {
|
|
||||||
val prev = previous?.pubCaseLidState?.let { String.format("%02X", it.toByte()) }
|
|
||||||
val cur = current.pubCaseLidState.let { String.format("%02X", it.toByte()) }
|
|
||||||
"previous=$prev (${previous?.caseLidState}), current=$cur (${current.caseLidState})"
|
|
||||||
}
|
|
||||||
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
|
|
||||||
|
|
||||||
val isSameDeviceWithCaseNowOpen =
|
|
||||||
previous?.identifier == current.identifier && previous.caseLidState != current.caseLidState
|
|
||||||
val isNewDeviceWithJustOpenedCase =
|
|
||||||
previous?.identifier != current.identifier && previous?.caseLidState != current.caseLidState
|
|
||||||
|
|
||||||
if (!isSameDeviceWithCaseNowOpen && !isNewDeviceWithJustOpenedCase) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
log(TAG) { "Case lid status changed for monitored device." }
|
|
||||||
|
|
||||||
throttleCasePopUps(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun throttleCasePopUps(current: DualApplePods): Event? = when {
|
|
||||||
current.caseLidState == DualApplePods.LidState.OPEN -> {
|
|
||||||
log(TAG, INFO) { "Show popup" }
|
|
||||||
|
|
||||||
val now = Instant.now()
|
|
||||||
val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN
|
|
||||||
val sinceLastPop = Duration.between(lastShown, now)
|
|
||||||
log(TAG) { "Time since last case popup: $sinceLastPop" }
|
|
||||||
|
|
||||||
if (sinceLastPop >= Duration.ofSeconds(10)) {
|
|
||||||
caseCoolDowns[current.identifier] = Instant.now()
|
|
||||||
Event.PopupShow(device = current)
|
|
||||||
} else {
|
|
||||||
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current.caseLidState != DualApplePods.LidState.OPEN -> {
|
|
||||||
when (current.caseLidState) {
|
|
||||||
DualApplePods.LidState.CLOSED -> {
|
|
||||||
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
|
|
||||||
caseCoolDowns.remove(current.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
|
|
||||||
caseCoolDowns[current.identifier] = Instant.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(TAG, INFO) { "Hide popup" }
|
|
||||||
|
|
||||||
Event.PopupHide()
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private val connectionCoolDowns = mutableMapOf<BluetoothAddress, Instant>()
|
|
||||||
|
|
||||||
private fun monitorConnection(): Flow<Event> = reactionSettings.showPopUpOnConnection.flow
|
|
||||||
.flatMapLatest { isEnabled ->
|
|
||||||
if (!isEnabled) return@flatMapLatest emptyFlow()
|
|
||||||
|
|
||||||
combine(
|
|
||||||
generalSettings.mainDeviceAddress.flow,
|
|
||||||
bluetoothManager.connectedDevices().distinctUntilChanged(),
|
|
||||||
podMonitor.mainDevice.distinctUntilChangedBy { it?.rawDataHex },
|
|
||||||
) { targetAddress, devices, broadcast ->
|
|
||||||
log(TAG) { "$targetAddress $broadcast $devices " }
|
|
||||||
val direct = devices.singleOrNull { it.address == targetAddress }.also {
|
|
||||||
log(TAG, VERBOSE) { "Connected main device is $it" }
|
|
||||||
}
|
|
||||||
if (direct == null) {
|
|
||||||
connectionCoolDowns.remove(targetAddress).also {
|
|
||||||
if (it != null) log(TAG) { "Cleared connection cooldown for $targetAddress due to disconect" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (direct != null && broadcast != null) direct to broadcast else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.withPrevious()
|
|
||||||
.mapNotNull { (previouss, currents) ->
|
|
||||||
val previousConnected = previouss?.first
|
|
||||||
log(TAG, VERBOSE) { "previousConnected: $previousConnected" }
|
|
||||||
val previousBroadcasted = previouss?.second
|
|
||||||
log(TAG, VERBOSE) { "previousBroadcasted: $previousBroadcasted" }
|
|
||||||
val currentConnected = currents?.first
|
|
||||||
log(TAG, VERBOSE) { "currentConnected: $currentConnected" }
|
|
||||||
val currentBroadcasted = currents?.second
|
|
||||||
log(TAG, VERBOSE) { "currentBroadcasted: $currentBroadcasted" }
|
|
||||||
|
|
||||||
if (previousConnected != null && previousBroadcasted != null && currentConnected == null) {
|
|
||||||
return@mapNotNull Event.PopupHide()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentConnected == null || currentBroadcasted == null) {
|
|
||||||
// We need an active connection
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
|
|
||||||
val ageOfBroadcastedDevice = Duration.between(Instant.now(), currentBroadcasted.seenFirstAt)
|
|
||||||
val ageOfConnectedDevice = Duration.between(Instant.now(), currentConnected.seenFirstAt)
|
|
||||||
if (ageOfBroadcastedDevice > (ageOfConnectedDevice + Duration.ofSeconds(30))) {
|
|
||||||
// This is likely a false positive, some random nearby device
|
|
||||||
// We expect the first broadcasts to not be much older than the first connection
|
|
||||||
log(TAG, VERBOSE) { "Current broadcasted main device is probably a false-positive" }
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = Instant.now()
|
|
||||||
val lastShown = connectionCoolDowns[currentConnected.address]
|
|
||||||
val sinceLastPop = lastShown?.let { Duration.between(it, now) }
|
|
||||||
log(TAG) { "Time since last connection popup: ${sinceLastPop?.seconds}s" }
|
|
||||||
|
|
||||||
if (lastShown == null) {
|
|
||||||
connectionCoolDowns[currentConnected.address] = Instant.now()
|
|
||||||
Event.PopupShow(device = currentBroadcasted)
|
|
||||||
} else {
|
|
||||||
log(TAG) { "Connection popup is still on cooldown: $sinceLastPop" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setupCommonEventHandlers(TAG) { "popUpConnection" }
|
|
||||||
|
|
||||||
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
|
|
||||||
|
|
||||||
sealed class Event {
|
|
||||||
data class PopupShow(
|
|
||||||
val eventAt: Instant = Instant.now(),
|
|
||||||
val device: PodDevice,
|
|
||||||
) : Event()
|
|
||||||
|
|
||||||
data class PopupHide(
|
|
||||||
val eventAt: Instant = Instant.now(),
|
|
||||||
) : Event()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = logTag("Reaction", "PopUp")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="512"
|
|
||||||
android:viewportHeight="512"
|
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M125,52.87c-40.4,0 -80,23.12 -101.74,56.89V222.6c21,32.5 59.38,54.26 103.28,54.26a127.75,127.75 0,0 0,49.33 -9.78V438.13a21,21 0,0 0,21 21h28.91a21,21 0,0 0,21 -21V165.42C246.66,102.73 206.5,52.87 125,52.87ZM81.81,181.52a15.48,15.48 0,1 1,15.41 -15.47A15.44,15.44 0,0 1,81.81 181.52ZM228.25,430.8a6,6 0,0 1,-6 6H201.88a6,6 0,0 1,-6 -6v-0.38a6,6 0,0 1,6 -6h20.37a6,6 0,0 1,6 6Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M387,52.87c40.4,0 80,23.12 101.74,56.89L488.74,222.6c-21,32.5 -59.38,54.26 -103.28,54.26a127.75,127.75 0,0 1,-49.33 -9.78L336.13,438.13a21,21 0,0 1,-21 21L286.25,459.13a21,21 0,0 1,-21 -21L265.25,165.42C265.34,102.73 305.5,52.87 387,52.87ZM430.16,181.52a15.48,15.48 0,1 0,-15.41 -15.47A15.44,15.44 0,0 0,430.19 181.52ZM283.75,430.8a6,6 0,0 0,6 6h20.37a6,6 0,0 0,6 -6v-0.38a6,6 0,0 0,-6 -6L289.75,424.42a6,6 0,0 0,-6 6Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="512"
|
|
||||||
android:viewportHeight="512"
|
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M347.5,205.5h91v160a92,92 0,0 1,-92 92h-181a92,92 0,0 1,-92 -92v-160h101v10a29,29 0,0 0,29 29h115a29,29 0,0 0,29 -29Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M438.5,146.5v42H73.5v-42a92,92 0,0 1,92 -92h181A92,92 0,0 1,438.5 146.5Z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M209.5,225.5a10,10 0,0 1,-10 -10v-10h113v10a10,10 0,0 1,-10 10Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="512"
|
|
||||||
android:viewportHeight="512"
|
|
||||||
android:tint="?attr/colorControlNormal">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M246,52.87c-40.4,0 -80,23.12 -101.74,56.89V222.6c21,32.5 59.38,54.26 103.28,54.26a127.75,127.75 0,0 0,49.33 -9.78V438.13a21,21 0,0 0,21 21h28.91a21,21 0,0 0,21 -21V165.42C367.67,102.73 327.51,52.87 246,52.87ZM202.82,181.52a15.48,15.48 0,1 1,15.41 -15.47A15.44,15.44 0,0 1,202.82 181.52ZM349.26,430.8a6,6 0,0 1,-6 6H322.89a6,6 0,0 1,-6 -6v-0.38a6,6 0,0 1,6 -6h20.37a6,6 0,0 1,6 6Z" />
|
|
||||||
</vector>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user