mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
Compare commits
732 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c7f46514a7 | ||
![]() |
5335dc934c | ||
![]() |
4526eac31b | ||
![]() |
193ecd15d2 | ||
![]() |
c8de60085a | ||
![]() |
deff57a54f | ||
![]() |
a085a3564b | ||
![]() |
b4dfe91125 | ||
![]() |
d915f41b33 | ||
![]() |
3cfbbdef1c | ||
![]() |
fcb470b050 | ||
![]() |
529d61c3f1 | ||
![]() |
1a9b1d159c | ||
![]() |
f0b7c6e450 | ||
![]() |
2db717a1ca | ||
![]() |
366510804f | ||
![]() |
fe1c46f7df | ||
![]() |
4b4ce932bd | ||
![]() |
949f55e4b7 | ||
![]() |
af09fe7319 | ||
![]() |
d54f9f560e | ||
![]() |
d1070da473 | ||
![]() |
5117247e00 | ||
![]() |
c41452503b | ||
![]() |
213c6977f8 | ||
![]() |
aa5b9a25ae | ||
![]() |
b47b2845b5 | ||
![]() |
ebe44dd048 | ||
![]() |
d3a9cb0be0 | ||
![]() |
773f498aad | ||
![]() |
095415a78d | ||
![]() |
8866483e17 | ||
![]() |
f1ddcd5297 | ||
![]() |
8d6f2dad13 | ||
![]() |
abd616e94a | ||
![]() |
24876387d1 | ||
![]() |
b2ae731e96 | ||
![]() |
6a2cbd79e2 | ||
![]() |
e7095cde07 | ||
![]() |
6c3ac5d4f2 | ||
![]() |
2037c69f42 | ||
![]() |
1c91576afb | ||
![]() |
0872a1a2d9 | ||
![]() |
4c94b4ffa0 | ||
![]() |
48e66cbd7e | ||
![]() |
3486cac7b2 | ||
![]() |
c79f310fbe | ||
![]() |
73855d01ee | ||
![]() |
1bac86a88c | ||
![]() |
a88507a905 | ||
![]() |
8240a1c458 | ||
![]() |
da8dc613b6 | ||
![]() |
0089d9892d | ||
![]() |
28f36a435c | ||
![]() |
0133290ef1 | ||
![]() |
9490348c5c | ||
![]() |
7f644c4dc6 | ||
![]() |
061ed8b156 | ||
![]() |
2501ac32a6 | ||
![]() |
f6de5a4449 | ||
![]() |
73e99afadc | ||
![]() |
8cd2fa3b76 | ||
![]() |
ffd1405a61 | ||
![]() |
ac0fd3fed2 | ||
![]() |
8fcd5ca487 | ||
![]() |
ee4529a9dc | ||
![]() |
048e285f9c | ||
![]() |
7d949fc16c | ||
![]() |
42f06c67bb | ||
![]() |
5d0e84e4ab | ||
![]() |
f77aeb78c6 | ||
![]() |
b076b8c8ab | ||
![]() |
721a3daeb6 | ||
![]() |
df9443eb9b | ||
![]() |
6aff6c8683 | ||
![]() |
08084d207e | ||
![]() |
0ec149ee02 | ||
![]() |
6b19cb81ed | ||
![]() |
c226434204 | ||
![]() |
8c9143f1ec | ||
![]() |
f839416ce1 | ||
![]() |
3734008326 | ||
![]() |
7166ba1398 | ||
![]() |
d1fe46804e | ||
![]() |
798b6f2119 | ||
![]() |
14d6b36162 | ||
![]() |
fa88f59170 | ||
![]() |
03e80861e9 | ||
![]() |
39458bd76c | ||
![]() |
3db93cc96a | ||
![]() |
cfea5a9143 | ||
![]() |
7d83bca1fe | ||
![]() |
24928661c5 | ||
![]() |
53701067ef | ||
![]() |
8061ee4fc6 | ||
![]() |
a122cbea03 | ||
![]() |
e0dce7d359 | ||
![]() |
f8c1db495d | ||
![]() |
1b006ba45c | ||
![]() |
bc1dbf01db | ||
![]() |
29984c59a2 | ||
![]() |
a250ec1788 | ||
![]() |
4e93ff4bb2 | ||
![]() |
eb6baac503 | ||
![]() |
0461fe9596 | ||
![]() |
e4e23abcea | ||
![]() |
04c6cd92a3 | ||
![]() |
ac6cc7a5c0 | ||
![]() |
f9d20c67d7 | ||
![]() |
aaee0c5cb6 | ||
![]() |
dac133e6b3 | ||
![]() |
97acb6d977 | ||
![]() |
f06a79c1a8 | ||
![]() |
e968e7e589 | ||
![]() |
552acdacf0 | ||
![]() |
6687b173a5 | ||
![]() |
5cdbb605f2 | ||
![]() |
a6856476ce | ||
![]() |
0ebbf86450 | ||
![]() |
ea1a7701bd | ||
![]() |
bc609ae34b | ||
![]() |
9c5cba2c91 | ||
![]() |
e7961c436b | ||
![]() |
f895700e34 | ||
![]() |
7620754bd4 | ||
![]() |
24383a7bd8 | ||
![]() |
41f1aca833 | ||
![]() |
227e075c0b | ||
![]() |
a39efe8f68 | ||
![]() |
cf72b91f69 | ||
![]() |
dad8439d3d | ||
![]() |
fe24f3473f | ||
![]() |
6627289666 | ||
![]() |
671c8da56a | ||
![]() |
0a626e5f66 | ||
![]() |
bc6a4a4660 | ||
![]() |
c25decc777 | ||
![]() |
b98a8a2103 | ||
![]() |
386614800b | ||
![]() |
6f8b68c7b2 | ||
![]() |
38fc299fc6 | ||
![]() |
a73a1efcd9 | ||
![]() |
8d070b370e | ||
![]() |
86382afc1b | ||
![]() |
b0f7d98d1f | ||
![]() |
797ecce9ca | ||
![]() |
2de62d0d29 | ||
![]() |
213519daf5 | ||
![]() |
54c1e526ed | ||
![]() |
08edde3421 | ||
![]() |
ba50d48f23 | ||
![]() |
f426cfe287 | ||
![]() |
590f3f2d13 | ||
![]() |
e5d594a273 | ||
![]() |
af19eec613 | ||
![]() |
cc4af773fb | ||
![]() |
db58bfc408 | ||
![]() |
1fe99b2aa6 | ||
![]() |
880a81f919 | ||
![]() |
91f567882c | ||
![]() |
b0ae92226c | ||
![]() |
0ca094426e | ||
![]() |
011d760c74 | ||
![]() |
41b3801d60 | ||
![]() |
7e19a07303 | ||
![]() |
9862b906d1 | ||
![]() |
a887b52912 | ||
![]() |
5964966a8f | ||
![]() |
52717562b3 | ||
![]() |
8c84405389 | ||
![]() |
2250be19e0 | ||
![]() |
06af5e6ace | ||
![]() |
3cc5327435 | ||
![]() |
dd5344c2a0 | ||
![]() |
cfec7147fb | ||
![]() |
9202c21125 | ||
![]() |
88ba0444e8 | ||
![]() |
e71dabed85 | ||
![]() |
8a9d7abdd1 | ||
![]() |
54a608ad71 | ||
![]() |
afd114fd2b | ||
![]() |
e8aa6ccc95 | ||
![]() |
be2a1b9785 | ||
![]() |
c0b1651608 | ||
![]() |
20e79b9c83 | ||
![]() |
f06264072e | ||
![]() |
e9eb7089a9 | ||
![]() |
cfc3572cfa | ||
![]() |
e6d31a0ba9 | ||
![]() |
300825e20f | ||
![]() |
42f8262796 | ||
![]() |
caeb966598 | ||
![]() |
986bf1d98a | ||
![]() |
60ee8d9395 | ||
![]() |
3cbf822b88 | ||
![]() |
298ce13369 | ||
![]() |
08a6d557f8 | ||
![]() |
0372b44901 | ||
![]() |
c6932fd31d | ||
![]() |
e8df9fbc28 | ||
![]() |
b1fc546f3b | ||
![]() |
fc9e6d7721 | ||
![]() |
386d3cb733 | ||
![]() |
3f1e00ac82 | ||
![]() |
f35a54cfa6 | ||
![]() |
b8787586db | ||
![]() |
eeaeef07a8 | ||
![]() |
9bc6908bb9 | ||
![]() |
cba150b72e | ||
![]() |
7b9457fc58 | ||
![]() |
df820e59fd | ||
![]() |
29d4e5a86a | ||
![]() |
d24dd4cf4b | ||
![]() |
cf6c653ad3 | ||
![]() |
47fc65aaf2 | ||
![]() |
32d35c5cd7 | ||
![]() |
3a542f3a4d | ||
![]() |
da34df6b87 | ||
![]() |
2c09245697 | ||
![]() |
666f0bee70 | ||
![]() |
b581505c97 | ||
![]() |
e8337f2fc2 | ||
![]() |
5e3e2a2e22 | ||
![]() |
f4334bf26b | ||
![]() |
fa72ee096e | ||
![]() |
de72f88953 | ||
![]() |
d6ab56f027 | ||
![]() |
1187d83e86 | ||
![]() |
cb25c1bb90 | ||
![]() |
b707681a63 | ||
![]() |
69b76a7640 | ||
![]() |
7c646835e9 | ||
![]() |
61a63eeabb | ||
![]() |
4681a1d924 | ||
![]() |
ef6487903e | ||
![]() |
9d4345c2d1 | ||
![]() |
7c6e44dd69 | ||
![]() |
329b41b4c8 | ||
![]() |
c6dca0fc77 | ||
![]() |
35614a7b7a | ||
![]() |
7401454269 | ||
![]() |
99085b2176 | ||
![]() |
b1167577ef | ||
![]() |
ad4caafb23 | ||
![]() |
cb9a86be67 | ||
![]() |
8b4c46a931 | ||
![]() |
4950dc3505 | ||
![]() |
1128a6771d | ||
![]() |
4af8cd7318 | ||
![]() |
4126dded6e | ||
![]() |
76b384ffb6 | ||
![]() |
5f1b6dcb43 | ||
![]() |
ab108305ef | ||
![]() |
06f2e1de5e | ||
![]() |
029d9fe302 | ||
![]() |
28864ca264 | ||
![]() |
5416fcb046 | ||
![]() |
2d22a65506 | ||
![]() |
6661d457ea | ||
![]() |
025a8a3209 | ||
![]() |
a73fc6c48f | ||
![]() |
bf84396a86 | ||
![]() |
7dfa29856e | ||
![]() |
3cc34faa02 | ||
![]() |
12311d392b | ||
![]() |
e719c0f8fb | ||
![]() |
76752e9004 | ||
![]() |
df0bb35672 | ||
![]() |
4f9f65d0b1 | ||
![]() |
b1c77fe16a | ||
![]() |
ee391a914b | ||
![]() |
cda0b7f195 | ||
![]() |
ff8ea3e1f2 | ||
![]() |
a0640d13ab | ||
![]() |
b5ae6a735d | ||
![]() |
459a0b18ba | ||
![]() |
096cf56436 | ||
![]() |
b6346ef0e3 | ||
![]() |
750f6dc212 | ||
![]() |
3aa4caf9ed | ||
![]() |
101f8d46e9 | ||
![]() |
39895bcd40 | ||
![]() |
f462d5ff50 | ||
![]() |
c2a9791680 | ||
![]() |
1791c67518 | ||
![]() |
ed8ab1d7b0 | ||
![]() |
5f8abca57a | ||
![]() |
f772818b7b | ||
![]() |
fa5cd6fbca | ||
![]() |
4be2fc52e2 | ||
![]() |
3d355df522 | ||
![]() |
de30f681e8 | ||
![]() |
d1e4b68e33 | ||
![]() |
df78952cfb | ||
![]() |
d79aabab50 | ||
![]() |
3cba3382f3 | ||
![]() |
ce50ed1d68 | ||
![]() |
0192af584c | ||
![]() |
515c43deb5 | ||
![]() |
536cba8780 | ||
![]() |
453ee79b1a | ||
![]() |
e116c192f6 | ||
![]() |
4d2d73d562 | ||
![]() |
699acb5311 | ||
![]() |
55e00c132a | ||
![]() |
add9c8cde5 | ||
![]() |
60f53f3649 | ||
![]() |
8a3bfcae4d | ||
![]() |
7f757f46f3 | ||
![]() |
22876c3be5 | ||
![]() |
40eee79aa3 | ||
![]() |
817e9d96d0 | ||
![]() |
e7e7505592 | ||
![]() |
261753ad75 | ||
![]() |
e7989e2eba | ||
![]() |
7cf2e14df2 | ||
![]() |
b309d584a7 | ||
![]() |
b6d8bdf607 | ||
![]() |
e8d7b2b6f8 | ||
![]() |
9598cd45fa | ||
![]() |
4f265b23f8 | ||
![]() |
d4a5612b77 | ||
![]() |
b98718214c | ||
![]() |
5f7c6a9140 | ||
![]() |
65be9a1e3e | ||
![]() |
c40361aced | ||
![]() |
79b33ced2e | ||
![]() |
c38be920ec | ||
![]() |
569794d437 | ||
![]() |
3cee858b56 | ||
![]() |
0d618380fa | ||
![]() |
1be3912812 | ||
![]() |
fe9d9d7298 | ||
![]() |
1eb7a7dd9a | ||
![]() |
8c391a7504 | ||
![]() |
89baa35ed7 | ||
![]() |
448108a974 | ||
![]() |
b3bb43367a | ||
![]() |
3d17012fb7 | ||
![]() |
92d1d6582a | ||
![]() |
f75a1a8a33 | ||
![]() |
a5a93cedc9 | ||
![]() |
a9b6225717 | ||
![]() |
cb4f76efe0 | ||
![]() |
d9e707aeea | ||
![]() |
f033550f8f | ||
![]() |
d21580b0cb | ||
![]() |
f6bdd1b345 | ||
![]() |
0de720ccf4 | ||
![]() |
63198c316e | ||
![]() |
82116bc089 | ||
![]() |
85c9009259 | ||
![]() |
b50cb91561 | ||
![]() |
275446818e | ||
![]() |
3371600ebb | ||
![]() |
e6ae9cbcb7 | ||
![]() |
2010d8ad94 | ||
![]() |
bc2e88ef04 | ||
![]() |
b6bfac4eee | ||
![]() |
da7251fc80 | ||
![]() |
89ac35e92e | ||
![]() |
d0701868cf | ||
![]() |
703e783193 | ||
![]() |
0364a79dcb | ||
![]() |
18195c9893 | ||
![]() |
79ba18630c | ||
![]() |
6adff096d2 | ||
![]() |
7722127796 | ||
![]() |
e94bfded6c | ||
![]() |
e237a5c99e | ||
![]() |
d69bc7f4b1 | ||
![]() |
6948e11fca | ||
![]() |
8f8376cd16 | ||
![]() |
47b85e74d2 | ||
![]() |
c96c547484 | ||
![]() |
0050b5b5c7 | ||
![]() |
542170f189 | ||
![]() |
aa8fd2a37f | ||
![]() |
6605f44eec | ||
![]() |
fd07d04502 | ||
![]() |
a69e2e2034 | ||
![]() |
7f4323c2a8 | ||
![]() |
ed4dc4dca3 | ||
![]() |
42fbbf4d27 | ||
![]() |
66a54d8adf | ||
![]() |
dd58ce23a9 | ||
![]() |
68438a6a0b | ||
![]() |
ca33e57069 | ||
![]() |
558aa2c86f | ||
![]() |
b2a413f609 | ||
![]() |
eacf3cc2f1 | ||
![]() |
369050e94e | ||
![]() |
6b6a19ead3 | ||
![]() |
d6b9f56a60 | ||
![]() |
9dc1c05d69 | ||
![]() |
bfe8e8f844 | ||
![]() |
517516518f | ||
![]() |
3d7f053e8b | ||
![]() |
cc3b0736c9 | ||
![]() |
e7bc1ac0b3 | ||
![]() |
b6d0501c85 | ||
![]() |
fe9d2ef1e6 | ||
![]() |
a024ef9154 | ||
![]() |
d6a95c4ce1 | ||
![]() |
8c77376fa6 | ||
![]() |
e11c8c28f2 | ||
![]() |
b12aeaeec8 | ||
![]() |
ac544a9b8e | ||
![]() |
1029ff4e96 | ||
![]() |
584f4fc216 | ||
![]() |
0130962351 | ||
![]() |
2c58ab3c18 | ||
![]() |
8c45a863e7 | ||
![]() |
16e6fd8fc2 | ||
![]() |
cb43a1b371 | ||
![]() |
f80d3cbe16 | ||
![]() |
c6d20c74f2 | ||
![]() |
573a70cca8 | ||
![]() |
26f17869d6 | ||
![]() |
37a8a9871e | ||
![]() |
81520c48ba | ||
![]() |
1efb57d540 | ||
![]() |
f68baaf1bf | ||
![]() |
6ad7ff12e6 | ||
![]() |
f1296c32ec | ||
![]() |
15bb9b4051 | ||
![]() |
d0885ba877 | ||
![]() |
54ad067cdf | ||
![]() |
5a55619e55 | ||
![]() |
dd57ce513e | ||
![]() |
119782fb8f | ||
![]() |
17a52fdcb5 | ||
![]() |
1e5806000a | ||
![]() |
ac4102d5cd | ||
![]() |
d0d8996227 | ||
![]() |
6ef60942e0 | ||
![]() |
01a6f49b77 | ||
![]() |
dbec10c96e | ||
![]() |
fe443d7435 | ||
![]() |
0885a54770 | ||
![]() |
30f5ef34a3 | ||
![]() |
2c90347748 | ||
![]() |
5b85c07e4b | ||
![]() |
a8d93e0f6a | ||
![]() |
7fb8fba161 | ||
![]() |
cf5ebc84af | ||
![]() |
89e2212eec | ||
![]() |
4c3cf19d31 | ||
![]() |
fa8ef46a4f | ||
![]() |
784988897c | ||
![]() |
8b91e64a82 | ||
![]() |
9903f2984b | ||
![]() |
e2a9c3126d | ||
![]() |
16686b2d04 | ||
![]() |
be3255e1a1 | ||
![]() |
f94a588fe4 | ||
![]() |
f49159f4a6 | ||
![]() |
f6636854a8 | ||
![]() |
f608fce478 | ||
![]() |
3927d89544 | ||
![]() |
d1ef07e552 | ||
![]() |
ee7d3bedf3 | ||
![]() |
a59fa61385 | ||
![]() |
23dbcc2e25 | ||
![]() |
b151d324e7 | ||
![]() |
676b226c7c | ||
![]() |
a0f977416c | ||
![]() |
87b1c27acb | ||
![]() |
5585e79735 | ||
![]() |
9ad2ab8372 | ||
![]() |
d50e20e041 | ||
![]() |
043c86dbef | ||
![]() |
a3fba67786 | ||
![]() |
3c593caae7 | ||
![]() |
584fa59d58 | ||
![]() |
78d33ad9a9 | ||
![]() |
4488e123f4 | ||
![]() |
c2586c37a2 | ||
![]() |
47f5f047ed | ||
![]() |
daa5e6d1f0 | ||
![]() |
07d043a143 | ||
![]() |
44cbc30254 | ||
![]() |
980d7895ca | ||
![]() |
7245b9caf6 | ||
![]() |
9ce62867c4 | ||
![]() |
fc2f3ae772 | ||
![]() |
8ac210a4bd | ||
![]() |
186240fbb4 | ||
![]() |
7857266778 | ||
![]() |
75c5191f98 | ||
![]() |
8f3531de06 | ||
![]() |
b343d4a085 | ||
![]() |
ac0fd14a17 | ||
![]() |
e8d51896c5 | ||
![]() |
670aed94ab | ||
![]() |
f0fc844066 | ||
![]() |
cf42dc12e9 | ||
![]() |
75168b30fb | ||
![]() |
d851a7ce7b | ||
![]() |
8c367ea4c0 | ||
![]() |
4f567d921a | ||
![]() |
4c6a6a2584 | ||
![]() |
ba385700c0 | ||
![]() |
5b6488b814 | ||
![]() |
388f99e7f6 | ||
![]() |
e37aeee765 | ||
![]() |
22f7464040 | ||
![]() |
e8ff64d4ab | ||
![]() |
5cfe3e9181 | ||
![]() |
92fc939da4 | ||
![]() |
8fc0dbe6a3 | ||
![]() |
3c1244abe6 | ||
![]() |
1526ee6f4b | ||
![]() |
3976179e64 | ||
![]() |
26e0f21911 | ||
![]() |
2017dff461 | ||
![]() |
58e544aabe | ||
![]() |
bd0eae46f1 | ||
![]() |
f129ed92f0 | ||
![]() |
18f2181966 | ||
![]() |
315adabebc | ||
![]() |
a7f6bb2855 | ||
![]() |
8cb9645db4 | ||
![]() |
595905d02d | ||
![]() |
768445bfbf | ||
![]() |
8f443a2770 | ||
![]() |
19f092bf2a | ||
![]() |
09d5fb728e | ||
![]() |
7bd4a94042 | ||
![]() |
9a714af6e1 | ||
![]() |
56c379b607 | ||
![]() |
c94a69e43e | ||
![]() |
2ab015d928 | ||
![]() |
f25ac075e7 | ||
![]() |
665ba94410 | ||
![]() |
f989604e8b | ||
![]() |
82f81b4ec8 | ||
![]() |
46cce55132 | ||
![]() |
378e81caee | ||
![]() |
91d7f7c8c1 | ||
![]() |
e7fb2b54a0 | ||
![]() |
2134839c30 | ||
![]() |
4dbc9fa8eb | ||
![]() |
48d6663a96 | ||
![]() |
b0510841c7 | ||
![]() |
0d7a82930b | ||
![]() |
1c0b816786 | ||
![]() |
e3ed1e5418 | ||
![]() |
4613b19794 | ||
![]() |
f928aefe24 | ||
![]() |
21cd9b059b | ||
![]() |
b5dc7cf945 | ||
![]() |
5a984c686b | ||
![]() |
8cf558098b | ||
![]() |
21811b39ef | ||
![]() |
e5bbffbcaa | ||
![]() |
767d56354a | ||
![]() |
fd01b491d6 | ||
![]() |
222883c984 | ||
![]() |
ffd002f3ed | ||
![]() |
4847271ea9 | ||
![]() |
322c09c90a | ||
![]() |
f5549b49f9 | ||
![]() |
25bc10cc97 | ||
![]() |
543ac0be58 | ||
![]() |
2181609169 | ||
![]() |
56139b5f3e | ||
![]() |
9c1d263917 | ||
![]() |
7e90ae1f20 | ||
![]() |
ef91de0d4c | ||
![]() |
9d58bd4163 | ||
![]() |
c46aafdeb0 | ||
![]() |
de0e163b28 | ||
![]() |
739d5cc738 | ||
![]() |
7fe6ab79e1 | ||
![]() |
756323fcdf | ||
![]() |
f1ef9b3069 | ||
![]() |
20457d0308 | ||
![]() |
3b207f6903 | ||
![]() |
6b7a8eb654 | ||
![]() |
db6bdf71fe | ||
![]() |
5f8063090e | ||
![]() |
82b17b39e6 | ||
![]() |
8a5a98e092 | ||
![]() |
aab39eca8e | ||
![]() |
35f1ea6a3a | ||
![]() |
90e1a0937a | ||
![]() |
354b3dc41c | ||
![]() |
e282488670 | ||
![]() |
6f5a5352cc | ||
![]() |
20da4acc04 | ||
![]() |
889525bceb | ||
![]() |
81592a2df9 | ||
![]() |
93ab1fcda4 | ||
![]() |
8481946123 | ||
![]() |
43bf2f0c01 | ||
![]() |
98f335c9bf | ||
![]() |
f64e7bac6c | ||
![]() |
a09b146b7a | ||
![]() |
37c8e131ed | ||
![]() |
76fca329b7 | ||
![]() |
4889a49d1b | ||
![]() |
ea249b0b4f | ||
![]() |
56b6c01b71 | ||
![]() |
e670c4b91a | ||
![]() |
d57a14a2cb | ||
![]() |
edd7690c48 | ||
![]() |
c31361e607 | ||
![]() |
ed48865985 | ||
![]() |
6de6e8e9ae | ||
![]() |
dabfff1e30 | ||
![]() |
876e51f641 | ||
![]() |
9c19a26775 | ||
![]() |
8f98776325 | ||
![]() |
ba291781da | ||
![]() |
35c90a6edb | ||
![]() |
778c73cdff | ||
![]() |
f4a7b0657d | ||
![]() |
08641e4b7b | ||
![]() |
0696eaf3ae | ||
![]() |
95a6f492ac | ||
![]() |
1b4938fab1 | ||
![]() |
f22773757a | ||
![]() |
70b4e4b75c | ||
![]() |
6406e495bd | ||
![]() |
ef41fd03fc | ||
![]() |
852320c1b0 | ||
![]() |
dbbf19e195 | ||
![]() |
e6b416a7b5 | ||
![]() |
48949fd8ac | ||
![]() |
00ec3b8db1 | ||
![]() |
6158baca38 | ||
![]() |
4a17ed7fdf | ||
![]() |
8e8671ba31 | ||
![]() |
babea7fbc0 | ||
![]() |
6c935f7f96 | ||
![]() |
df7c7bd652 | ||
![]() |
8f2ec76d87 | ||
![]() |
0954881e3d | ||
![]() |
169d912a3f | ||
![]() |
bbdda94cd8 | ||
![]() |
648dde28d4 | ||
![]() |
0618036b5c | ||
![]() |
f61f9ab9a0 | ||
![]() |
bf2fda73d4 | ||
![]() |
a82fa6d341 | ||
![]() |
9f988f4ca8 | ||
![]() |
a3c606b26b | ||
![]() |
2ca4559b77 | ||
![]() |
aa8f56aced | ||
![]() |
a6c68b41b5 | ||
![]() |
c88503039f | ||
![]() |
48d4375542 | ||
![]() |
0dbabba785 | ||
![]() |
a85ee49f1a | ||
![]() |
f2283294b9 | ||
![]() |
2a1b24f4d7 | ||
![]() |
e77a1516d2 | ||
![]() |
c05da6075a | ||
![]() |
b6aff4abcc | ||
![]() |
26f1634a88 | ||
![]() |
f90ca6ef84 | ||
![]() |
cfaaa02e44 | ||
![]() |
3cb6c39a70 | ||
![]() |
f8788d4e3f | ||
![]() |
3dbdab09e6 | ||
![]() |
37981cebd1 | ||
![]() |
eca7e00428 | ||
![]() |
a2c4d935a6 | ||
![]() |
8fed6c068b | ||
![]() |
8248ab7f68 | ||
![]() |
faa4bb5269 | ||
![]() |
ab137370aa | ||
![]() |
c9fa72a744 | ||
![]() |
a9f528eef0 | ||
![]() |
607f2ad2c5 | ||
![]() |
9c84e86215 | ||
![]() |
9ba61dea67 | ||
![]() |
ea5b15175e | ||
![]() |
b1775d46d8 | ||
![]() |
0b7d312170 | ||
![]() |
2bf43c21da | ||
![]() |
b918d2061b | ||
![]() |
31d573ad89 | ||
![]() |
92b9e85540 | ||
![]() |
4aa53962b6 | ||
![]() |
336efcfe03 | ||
![]() |
97ec04f579 | ||
![]() |
1460a8ff5b | ||
![]() |
b5900159d3 | ||
![]() |
0219c88a40 | ||
![]() |
a4a6f85e75 | ||
![]() |
4ba0c64f54 | ||
![]() |
543f06eee7 | ||
![]() |
54b2e9bee5 | ||
![]() |
5e9f46d979 | ||
![]() |
d9420ddff5 | ||
![]() |
eceaba78be | ||
![]() |
41af9004a2 | ||
![]() |
3345ee6eb8 | ||
![]() |
c994a47d8a | ||
![]() |
0fb341ffb9 | ||
![]() |
35cff4b6eb | ||
![]() |
d88b03142f | ||
![]() |
7b2df0ae0d | ||
![]() |
689d830c77 | ||
![]() |
8fd57aace3 | ||
![]() |
d559fb45a5 | ||
![]() |
76e10a1512 | ||
![]() |
38df00898d | ||
![]() |
c15c4b59fa | ||
![]() |
3f72efc8e6 | ||
![]() |
07f3c49a88 | ||
![]() |
dc7a5648a5 | ||
![]() |
e9e83e00d1 | ||
![]() |
c73f2c3189 | ||
![]() |
a4edfa539f | ||
![]() |
e4e8ae0158 | ||
![]() |
14abd1aee0 | ||
![]() |
07757f34bb | ||
![]() |
78453f1c4d | ||
![]() |
825f0eb33f | ||
![]() |
5b7ce77ad3 | ||
![]() |
027e41d6b6 | ||
![]() |
69b4207124 | ||
![]() |
a515d2b36c | ||
![]() |
57424cc1d3 | ||
![]() |
6e26681acf | ||
![]() |
df1d7ce8ff | ||
![]() |
862de21436 | ||
![]() |
46dfee9467 | ||
![]() |
94a85acb56 |
9
.github/actions/prepare-keystore/action.yml
vendored
9
.github/actions/prepare-keystore/action.yml
vendored
@ -19,16 +19,13 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Write Keystore file 🗄️
|
||||
id: android_keystore
|
||||
uses: timheuer/base64-to-file@v1.0.3
|
||||
with:
|
||||
fileName: key.jks
|
||||
encodedString: ${{ inputs.keyStoreBase64 }}
|
||||
shell: bash
|
||||
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
|
||||
|
||||
- name: Write Keystore properties 🗝️
|
||||
shell: bash
|
||||
run: |
|
||||
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
|
||||
echo "storeFile=/home/runner/key.jks" > key.properties
|
||||
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
||||
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
||||
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
||||
|
13
.github/workflows/build-testing.yaml
vendored
13
.github/workflows/build-testing.yaml
vendored
@ -7,15 +7,15 @@ jobs:
|
||||
debug-builds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "adopt"
|
||||
java-version: 19
|
||||
java-version: 21
|
||||
cache: "gradle"
|
||||
|
||||
- name: Compile
|
||||
@ -23,6 +23,7 @@ jobs:
|
||||
./gradlew assembleDebug
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
name: alibi-app-debug-apks
|
||||
path: app/build/outputs/apk/debug/app-*-debug.apk
|
||||
|
19
.github/workflows/release-app-github.yaml
vendored
19
.github/workflows/release-app-github.yaml
vendored
@ -10,7 +10,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Write KeyStore 🗝️
|
||||
uses: ./.github/actions/prepare-keystore
|
||||
@ -21,10 +23,10 @@ jobs:
|
||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: "17.x"
|
||||
java-version: 21
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build APKs 📱
|
||||
@ -37,3 +39,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
files: app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Build AABs 📱
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
- name: Upload APKs bundles 🚀
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
files: app/build/outputs/bundle/release/*.aab
|
||||
|
12
.github/workflows/release-app-google-play.yaml
vendored
12
.github/workflows/release-app-google-play.yaml
vendored
@ -1,4 +1,4 @@
|
||||
name: Build and publish app
|
||||
name: Build and publish app to Google Play
|
||||
|
||||
on:
|
||||
release:
|
||||
@ -10,7 +10,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Write KeyStore 🗝️
|
||||
uses: ./.github/actions/prepare-keystore
|
||||
@ -21,10 +23,10 @@ jobs:
|
||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: "17.x"
|
||||
java-version: 21
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build APKs 📱
|
||||
@ -39,4 +41,4 @@ jobs:
|
||||
track: production
|
||||
status: inProgress
|
||||
inAppUpdatePriority: 2
|
||||
userFraction: 0.33
|
||||
userFraction: 0.2
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
|
28
README.md
28
README.md
@ -3,18 +3,21 @@
|
||||
# Alibi
|
||||
|
||||
<p float="left" align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.webp" width="30%" />
|
||||
</p>
|
||||
|
||||
Alibi keeps recording in the background and saves the last 30 minutes at your request.
|
||||
Alibi keeps recording audio/video in the background and saves the last 30 minutes at your request.
|
||||
Everything is completely configurable. No internet connection required.
|
||||
|
||||
# Download
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/app.myzel394.alibi)
|
||||
[<img src="readme_content/google-play-badge.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=app.myzel394.alibi)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/app.myzel394.alibi)
|
||||
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
||||
|
||||
# Supporting Alibi
|
||||
@ -27,16 +30,13 @@ Add a new feature or fix bugs.
|
||||
|
||||
## Add translations
|
||||
|
||||
Translate Alibi into your language so that other people can use it more easily.
|
||||
[Translate Alibi into your language using Crowdin](https://crowdin.com/project/alibi), so that other
|
||||
people can use it more easily.
|
||||
|
||||
## Donate
|
||||
|
||||
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
|
||||
it's a really small amount and if everyone did that, I can totally focus on Alibi and my other open
|
||||
It might sound crazy, but if you would just donate $ 1, it would totally mean the world to me, since
|
||||
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
|
||||
source projects. :)
|
||||
|
||||
You can donate via:
|
||||
|
||||
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
|
||||
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
|
||||
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
|
||||
You can donate via [GitHub Sponsors](https://github.com/sponsors/Myzel394) or via [crypto currencies](https://github.com/Myzel394/contact-me?tab=readme-ov-file#donations).
|
||||
|
@ -35,8 +35,8 @@ android {
|
||||
applicationId "app.myzel394.alibi"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 6
|
||||
versionName "0.2.3"
|
||||
versionCode 16
|
||||
versionName "0.5.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -78,9 +78,11 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.1'
|
||||
kotlinCompilerExtensionVersion '1.5.10'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@ -90,41 +92,60 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||
implementation 'androidx.activity:activity-compose:1.9.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||
implementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-graphics'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.5.1"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
|
||||
implementation "androidx.navigation:navigation-compose:2.7.2"
|
||||
implementation "androidx.navigation:navigation-compose:2.7.7"
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.46.1'
|
||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
|
||||
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
|
||||
implementation 'com.google.dagger:hilt-android:2.49'
|
||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
|
||||
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
|
||||
|
||||
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
||||
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1'
|
||||
|
||||
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
||||
|
||||
def camerax_version = "1.3.4"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-video:${camerax_version}"
|
||||
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
implementation "androidx.camera:camera-extensions:${camerax_version}"
|
||||
|
||||
|
||||
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
|
||||
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
}
|
@ -2,13 +2,40 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Required for Bluetooth microphones -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<!-- Starting with Android 29, apps don't need to request the READ_EXTERNAL_STORAGE permission
|
||||
for files in their own MediaStore -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".UpdateSettingsApp"
|
||||
android:allowBackup="true"
|
||||
@ -19,6 +46,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Alibi"
|
||||
android:hardwareAccelerated="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@ -39,7 +67,13 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
|
||||
|
||||
<service
|
||||
android:name=".services.AudioRecorderService"
|
||||
android:foregroundServiceType="microphone" />
|
||||
<service
|
||||
android:name=".services.VideoRecorderService"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
|
||||
<!-- Change locale for Android <= 12 -->
|
||||
<service
|
||||
|
@ -1,3 +1,3 @@
|
||||
package app.myzel394.alibi
|
||||
|
||||
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE")
|
||||
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE", "tr-TR")
|
@ -2,13 +2,18 @@ package app.myzel394.alibi
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.datastore.dataStore
|
||||
import app.myzel394.alibi.db.AppSettingsSerializer
|
||||
import app.myzel394.alibi.ui.AsLockedApp
|
||||
import app.myzel394.alibi.ui.LockedAppHandlers
|
||||
import app.myzel394.alibi.ui.Navigation
|
||||
import app.myzel394.alibi.ui.theme.AlibiTheme
|
||||
|
||||
@ -26,7 +31,19 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContent {
|
||||
AlibiTheme {
|
||||
Navigation()
|
||||
LockedAppHandlers()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background
|
||||
)
|
||||
) {
|
||||
AsLockedApp {
|
||||
Navigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,45 @@
|
||||
package app.myzel394.alibi.db
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
@Serializable
|
||||
data class AppSettings(
|
||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
|
||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
|
||||
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
|
||||
|
||||
val appLockSettings: AppLockSettings? = null,
|
||||
|
||||
val hasSeenOnboarding: Boolean = false,
|
||||
val showAdvancedSettings: Boolean = false,
|
||||
val theme: Theme = Theme.SYSTEM,
|
||||
val lastRecording: RecordingInformation? = null,
|
||||
|
||||
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
|
||||
|
||||
/// Recording information
|
||||
// 30 minutes
|
||||
val maxDuration: Long = 15 * 60 * 1000L,
|
||||
// 60 seconds
|
||||
val intervalDuration: Long = 60 * 1000L,
|
||||
|
||||
val notificationSettings: NotificationSettings? = null,
|
||||
val deleteRecordingsImmediately: Boolean = false,
|
||||
val saveFolder: String? = null,
|
||||
) {
|
||||
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
||||
return copy(showAdvancedSettings = showAdvancedSettings)
|
||||
@ -25,123 +49,166 @@ data class AppSettings(
|
||||
return copy(audioRecorderSettings = audioRecorderSettings)
|
||||
}
|
||||
|
||||
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
|
||||
return copy(videoRecorderSettings = videoRecorderSettings)
|
||||
}
|
||||
|
||||
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
|
||||
return copy(notificationSettings = notificationSettings)
|
||||
}
|
||||
|
||||
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
|
||||
return copy(hasSeenOnboarding = hasSeenOnboarding)
|
||||
}
|
||||
|
||||
fun setTheme(theme: Theme): AppSettings {
|
||||
return copy(theme = theme)
|
||||
}
|
||||
|
||||
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
|
||||
return copy(lastRecording = lastRecording)
|
||||
}
|
||||
|
||||
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
|
||||
return copy(filenameFormat = filenameFormat)
|
||||
}
|
||||
|
||||
fun setMaxDuration(duration: Long): AppSettings {
|
||||
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
|
||||
throw Exception("Max duration must be between 1 minute and 10 days")
|
||||
}
|
||||
|
||||
if (duration < intervalDuration) {
|
||||
throw Exception("Max duration must be greater than interval duration")
|
||||
}
|
||||
|
||||
return copy(maxDuration = duration)
|
||||
}
|
||||
|
||||
fun setIntervalDuration(duration: Long): AppSettings {
|
||||
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
||||
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
||||
}
|
||||
|
||||
if (duration > maxDuration) {
|
||||
throw Exception("Interval duration must be less than max duration")
|
||||
}
|
||||
|
||||
return copy(intervalDuration = duration)
|
||||
}
|
||||
|
||||
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
|
||||
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
|
||||
}
|
||||
|
||||
fun setSaveFolder(saveFolder: String?): AppSettings {
|
||||
return copy(saveFolder = saveFolder)
|
||||
}
|
||||
|
||||
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
|
||||
return copy(appLockSettings = appLockSettings)
|
||||
}
|
||||
|
||||
fun saveLastRecording(recorder: RecorderModel): AppSettings {
|
||||
return if (deleteRecordingsImmediately) {
|
||||
this
|
||||
} else {
|
||||
setLastRecording(
|
||||
recorder.recorderService!!.getRecordingInformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If the object is present, biometric authentication is enabled.
|
||||
// To disable biometric authentication, set the instance to null.
|
||||
fun isAppLockEnabled() = appLockSettings != null
|
||||
|
||||
fun requiresExternalStoragePermission(context: Context): Boolean {
|
||||
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
))
|
||||
}
|
||||
|
||||
fun exportToString(): String {
|
||||
return Json.encodeToString(serializer(), this)
|
||||
}
|
||||
|
||||
enum class Theme {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK,
|
||||
}
|
||||
|
||||
enum class FilenameFormat {
|
||||
DATETIME_ABSOLUTE_START,
|
||||
DATETIME_RELATIVE_START,
|
||||
DATETIME_NOW,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance(): AppSettings = AppSettings()
|
||||
|
||||
fun fromExportedString(data: String): AppSettings {
|
||||
return Json.decodeFromString(
|
||||
serializer(),
|
||||
data,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LastRecording(
|
||||
data class RecordingInformation(
|
||||
val folderPath: String,
|
||||
@Serializable(with = LocalDateTimeSerializer::class)
|
||||
val recordingStart: LocalDateTime,
|
||||
val batchesAmount: Int,
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val fileExtension: String,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
val type: Type,
|
||||
) {
|
||||
val fileFolder: File
|
||||
get() = File(folderPath)
|
||||
fun hasRecordingsAvailable(context: Context): Boolean =
|
||||
when (type) {
|
||||
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
|
||||
.hasRecordingsAvailable()
|
||||
|
||||
val filePaths: List<File>
|
||||
get() =
|
||||
File(folderPath).listFiles()?.filter {
|
||||
val name = it.nameWithoutExtension
|
||||
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
|
||||
.hasRecordingsAvailable()
|
||||
}
|
||||
|
||||
name.toIntOrNull() != null
|
||||
}?.toList() ?: emptyList()
|
||||
|
||||
val hasRecordingAvailable: Boolean
|
||||
get() = filePaths.isNotEmpty()
|
||||
|
||||
private fun stripConcatenatedFileToExactDuration(
|
||||
outputFile: File
|
||||
) {
|
||||
// Move the concatenated file to a temporary file
|
||||
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
|
||||
outputFile.renameTo(rawFile)
|
||||
|
||||
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
|
||||
return when (filenameFormat) {
|
||||
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
|
||||
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
|
||||
getFullDuration() / 1000
|
||||
)
|
||||
|
||||
throw Exception("Failed to strip concatenated audio")
|
||||
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||
val paths = filePaths.joinToString("|")
|
||||
val fileName = recordingStart
|
||||
.format(ISO_DATE_TIME)
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "_")
|
||||
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
|
||||
fun getFullDuration(): Long {
|
||||
// This is not accurate, since the last batch may be shorter than the others
|
||||
// but it's good enough
|
||||
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
|
||||
}
|
||||
|
||||
if (outputFile.exists() && !forceConcatenation) {
|
||||
return outputFile
|
||||
}
|
||||
|
||||
val command = "-i 'concat:$paths' -y" +
|
||||
" -acodec copy" +
|
||||
" -metadata title='$fileName' " +
|
||||
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
|
||||
" -metadata batch_count='${filePaths.size}'" +
|
||||
" -metadata batch_duration='${intervalDuration}'" +
|
||||
" -metadata max_duration='${maxDuration}'" +
|
||||
" $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Failed to concatenate audios")
|
||||
}
|
||||
|
||||
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
|
||||
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
||||
stripConcatenatedFileToExactDuration(outputFile)
|
||||
}
|
||||
|
||||
return outputFile
|
||||
enum class Type {
|
||||
AUDIO,
|
||||
VIDEO,
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AudioRecorderSettings(
|
||||
val maxDuration: Long = 30 * 60 * 1000L,
|
||||
// 60 seconds
|
||||
val intervalDuration: Long = 60 * 1000L,
|
||||
val forceExactMaxDuration: Boolean = true,
|
||||
// 320 Kbps
|
||||
val bitRate: Int = 320000,
|
||||
val samplingRate: Int? = null,
|
||||
val outputFormat: Int? = null,
|
||||
val encoder: Int? = null,
|
||||
val showAllMicrophones: Boolean = false,
|
||||
) {
|
||||
fun getOutputFormat(): Int {
|
||||
if (outputFormat != null) {
|
||||
@ -154,7 +221,7 @@ data class AudioRecorderSettings(
|
||||
else MediaRecorder.OutputFormat.THREE_GPP
|
||||
}
|
||||
|
||||
return when(encoder) {
|
||||
return when (encoder) {
|
||||
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
||||
@ -167,6 +234,7 @@ data class AudioRecorderSettings(
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
}
|
||||
}
|
||||
|
||||
MediaRecorder.AudioEncoder.OPUS -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaRecorder.OutputFormat.OGG
|
||||
@ -174,11 +242,12 @@ data class AudioRecorderSettings(
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
}
|
||||
}
|
||||
|
||||
else -> MediaRecorder.OutputFormat.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
fun getMimeType(): String = when(getOutputFormat()) {
|
||||
fun getMimeType(): String = when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
||||
@ -190,7 +259,7 @@ data class AudioRecorderSettings(
|
||||
else -> "audio/3gpp"
|
||||
}
|
||||
|
||||
fun getSamplingRate(): Int = samplingRate ?: when(getOutputFormat()) {
|
||||
fun getSamplingRate(): Int = samplingRate ?: when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
||||
@ -202,26 +271,12 @@ data class AudioRecorderSettings(
|
||||
else -> 48000
|
||||
}
|
||||
|
||||
fun getEncoder(): Int = encoder ?:
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.AudioEncoder.AAC
|
||||
else
|
||||
MediaRecorder.AudioEncoder.AMR_NB
|
||||
|
||||
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
|
||||
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
||||
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
||||
}
|
||||
|
||||
if (duration > maxDuration) {
|
||||
throw Exception("Interval duration must be less than max duration")
|
||||
}
|
||||
|
||||
return copy(intervalDuration = duration)
|
||||
}
|
||||
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.AudioEncoder.AAC
|
||||
else
|
||||
MediaRecorder.AudioEncoder.AMR_NB
|
||||
|
||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||
println("bitRate: $bitRate")
|
||||
if (bitRate !in 1000..320000) {
|
||||
throw Exception("Bit rate must be between 1000 and 320000")
|
||||
}
|
||||
@ -253,20 +308,8 @@ data class AudioRecorderSettings(
|
||||
return copy(encoder = encoder)
|
||||
}
|
||||
|
||||
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
||||
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
|
||||
throw Exception("Max duration must be between 1 minute and 1 hour")
|
||||
}
|
||||
|
||||
if (duration < intervalDuration) {
|
||||
throw Exception("Max duration must be greater than interval duration")
|
||||
}
|
||||
|
||||
return copy(maxDuration = duration)
|
||||
}
|
||||
|
||||
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
|
||||
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
||||
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||
return copy(showAllMicrophones = showAllMicrophones)
|
||||
}
|
||||
|
||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||
@ -279,17 +322,31 @@ data class AudioRecorderSettings(
|
||||
return supportedFormats.contains(outputFormat)
|
||||
}
|
||||
|
||||
val fileExtension: String
|
||||
get() = when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
||||
val EXAMPLE_MAX_DURATIONS = listOf(
|
||||
1 * 60 * 1000L,
|
||||
5 * 60 * 1000L,
|
||||
15 * 60 * 1000L,
|
||||
30 * 60 * 1000L,
|
||||
60 * 60 * 1000L,
|
||||
2 * 60 * 60 * 1000L,
|
||||
3 * 60 * 60 * 1000L,
|
||||
)
|
||||
val EXAMPLE_DURATION_TIMES = listOf(
|
||||
60 * 1000L,
|
||||
60 * 2 * 1000L,
|
||||
60 * 5 * 1000L,
|
||||
60 * 10 * 1000L,
|
||||
60 * 15 * 1000L,
|
||||
@ -387,3 +444,174 @@ data class AudioRecorderSettings(
|
||||
}).toMap()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoRecorderSettings(
|
||||
val targetedVideoBitRate: Int? = null,
|
||||
val quality: String? = null,
|
||||
val targetFrameRate: Int? = null,
|
||||
) {
|
||||
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
|
||||
return copy(targetedVideoBitRate = bitRate)
|
||||
}
|
||||
|
||||
fun setQuality(quality: Quality?): VideoRecorderSettings {
|
||||
val invertedMap = QUALITY_NAME_QUALITY_MAP.entries.associateBy({ it.value }, { it.key })
|
||||
|
||||
return copy(quality = quality?.let { invertedMap[it] })
|
||||
}
|
||||
|
||||
fun setTargetFrameRate(frameRate: Int?): VideoRecorderSettings {
|
||||
return copy(targetFrameRate = frameRate)
|
||||
}
|
||||
|
||||
fun getQuality(): Quality? =
|
||||
quality?.let {
|
||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
||||
}
|
||||
|
||||
fun getQualitySelector(): QualitySelector? =
|
||||
quality?.let {
|
||||
QualitySelector.from(
|
||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
||||
)
|
||||
}
|
||||
|
||||
fun getMimeType() = "video/$fileExtension"
|
||||
|
||||
val fileExtension
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "mp4" else "3gp"
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance() = VideoRecorderSettings()
|
||||
|
||||
val QUALITY_NAME_QUALITY_MAP: Map<String, Quality> = mapOf(
|
||||
"LOWEST" to Quality.LOWEST,
|
||||
"HIGHEST" to Quality.HIGHEST,
|
||||
"SD" to Quality.SD,
|
||||
"HD" to Quality.HD,
|
||||
"FHD" to Quality.FHD,
|
||||
"UHD" to Quality.UHD,
|
||||
)
|
||||
|
||||
val EXAMPLE_BITRATE_VALUES = listOf(
|
||||
null,
|
||||
500 * 1000,
|
||||
// 1 Mbps
|
||||
1 * 1000 * 1000,
|
||||
2 * 1000 * 1000,
|
||||
4 * 1000 * 1000,
|
||||
8 * 1000 * 1000,
|
||||
16 * 1000 * 1000,
|
||||
32 * 1000 * 1000,
|
||||
50 * 1000 * 1000,
|
||||
100 * 1000 * 1000,
|
||||
)
|
||||
|
||||
val EXAMPLE_FRAME_RATE_VALUES = listOf(
|
||||
null,
|
||||
24,
|
||||
30,
|
||||
60,
|
||||
120,
|
||||
240,
|
||||
)
|
||||
|
||||
val AVAILABLE_QUALITIES = listOf(
|
||||
Quality.HIGHEST,
|
||||
Quality.UHD,
|
||||
Quality.FHD,
|
||||
Quality.HD,
|
||||
Quality.SD,
|
||||
Quality.LOWEST,
|
||||
)
|
||||
|
||||
val EXAMPLE_QUALITY_VALUES = listOf(
|
||||
null,
|
||||
) + AVAILABLE_QUALITIES
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationSettings(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val iconID: Int,
|
||||
val showOngoing: Boolean,
|
||||
val preset: Preset? = null,
|
||||
) {
|
||||
@Serializable
|
||||
sealed class Preset(
|
||||
val titleID: Int,
|
||||
val messageID: Int,
|
||||
val showOngoing: Boolean,
|
||||
val iconID: Int,
|
||||
) {
|
||||
@Serializable
|
||||
data object Default : Preset(
|
||||
R.string.ui_audioRecorder_state_recording_title,
|
||||
R.string.ui_recorder_state_recording_description,
|
||||
true,
|
||||
R.drawable.launcher_monochrome_noopacity,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Weather : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_weather_title,
|
||||
R.string.ui_recorder_state_recording_fake_weather_description,
|
||||
false,
|
||||
R.drawable.ic_cloud
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Player : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_player_title,
|
||||
R.string.ui_recorder_state_recording_fake_player_description,
|
||||
true,
|
||||
R.drawable.ic_note,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Browser : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_browser_title,
|
||||
R.string.ui_recorder_state_recording_fake_browser_description,
|
||||
true,
|
||||
R.drawable.ic_download,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object VPN : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_vpn_title,
|
||||
R.string.ui_recorder_state_recording_fake_vpn_description,
|
||||
false,
|
||||
R.drawable.ic_vpn,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPreset(preset: Preset): NotificationSettings {
|
||||
return NotificationSettings(
|
||||
title = "",
|
||||
message = "",
|
||||
showOngoing = preset.showOngoing,
|
||||
iconID = preset.iconID,
|
||||
preset = preset,
|
||||
)
|
||||
}
|
||||
|
||||
val PRESETS = listOf(
|
||||
Preset.Default,
|
||||
Preset.Weather,
|
||||
Preset.Player,
|
||||
Preset.Browser,
|
||||
Preset.VPN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AppLockSettings {
|
||||
companion object {
|
||||
fun getDefaultInstance() = AppLockSettings()
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AppSettingsSerializer: Serializer<AppSettings> {
|
||||
class AppSettingsSerializer : Serializer<AppSettings> {
|
||||
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppSettings {
|
||||
@ -39,8 +39,9 @@ class AppSettingsSerializer: Serializer<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||
return LocalDateTime.parse(decoder.decodeString())
|
||||
|
@ -1,7 +1,10 @@
|
||||
package app.myzel394.alibi.enums
|
||||
|
||||
enum class RecorderState {
|
||||
IDLE,
|
||||
STOPPED,
|
||||
RECORDING,
|
||||
PAUSED,
|
||||
|
||||
// Only used by the model to indicate that the service is not running
|
||||
IDLE
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class AppLockHelper {
|
||||
enum class SupportType {
|
||||
AVAILABLE,
|
||||
UNAVAILABLE,
|
||||
NONE_ENROLLED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSupportType(context: Context): SupportType {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> SupportType.AVAILABLE
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> SupportType.NONE_ENROLLED
|
||||
|
||||
else -> SupportType.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
context: Context,
|
||||
title: String,
|
||||
subtitle: String
|
||||
): CompletableDeferred<Boolean> {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
context as FragmentActivity,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
deferred.complete(false)
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
deferred.complete(true)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
deferred.complete(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
|
||||
return deferred
|
||||
}
|
||||
|
||||
fun closeApp(context: Context) {
|
||||
(context as? Activity)?.let {
|
||||
it.finishAndRemoveTask()
|
||||
it.finishAffinity()
|
||||
it.finish()
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,176 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles
|
||||
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AudioBatchesFolder(
|
||||
override val context: Context,
|
||||
override val type: BatchType,
|
||||
override val customFolder: DocumentFile? = null,
|
||||
override val subfolderName: String = AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||
) : BatchesFolder(
|
||||
context,
|
||||
type,
|
||||
customFolder,
|
||||
subfolderName,
|
||||
) {
|
||||
override val concatenationFunction = ::concatenateAudioFiles
|
||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
||||
override val scopedMediaContentUri: Uri = SCOPED_MEDIA_CONTENT_URI
|
||||
override val legacyMediaFolder = LEGACY_MEDIA_FOLDER
|
||||
|
||||
private var customFileFileDescriptor: ParcelFileDescriptor? = null
|
||||
private var mediaFileFileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
override fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
||||
"audio/${extension}",
|
||||
fileName,
|
||||
)!!).uri
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = fileName,
|
||||
mimeType = "audio/$extension",
|
||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
||||
)
|
||||
|
||||
return FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
mediaUri
|
||||
)!!
|
||||
} else {
|
||||
val path = arrayOf(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_SUBFOLDER_NAME,
|
||||
fileName,
|
||||
).joinToString("/")
|
||||
return File(path)
|
||||
.apply {
|
||||
createNewFile()
|
||||
}.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
runCatching {
|
||||
customFileFileDescriptor?.close()
|
||||
}
|
||||
runCatching {
|
||||
mediaFileFileDescriptor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun asCustomGetFileDescriptor(
|
||||
counter: Long,
|
||||
fileExtension: String,
|
||||
): FileDescriptor {
|
||||
runCatching {
|
||||
customFileFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val file =
|
||||
getCustomDefinedFolder().createFile("audio/$fileExtension", "$counter.$fileExtension")!!
|
||||
|
||||
customFileFileDescriptor = context.contentResolver.openFileDescriptor(file.uri, "w")!!
|
||||
|
||||
return customFileFileDescriptor!!.fileDescriptor
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun asMediaGetScopedStorageFileDescriptor(
|
||||
name: String,
|
||||
mimeType: String
|
||||
): FileDescriptor {
|
||||
runCatching {
|
||||
mediaFileFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = name,
|
||||
mimeType = mimeType,
|
||||
relativePath = SCOPED_STORAGE_RELATIVE_PATH,
|
||||
)
|
||||
|
||||
mediaFileFileDescriptor = context.contentResolver.openFileDescriptor(mediaUri, "w")!!
|
||||
|
||||
return mediaFileFileDescriptor!!.fileDescriptor
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun viaInternalFolder(context: Context) = AudioBatchesFolder(context, BatchType.INTERNAL)
|
||||
|
||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
||||
AudioBatchesFolder(context, BatchType.CUSTOM, folder)
|
||||
|
||||
fun viaMediaFolder(context: Context) = AudioBatchesFolder(context, BatchType.MEDIA)
|
||||
|
||||
fun importFromFolder(folder: String, context: Context) = when (folder) {
|
||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
||||
else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!)
|
||||
}
|
||||
|
||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_PODCASTS
|
||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.audio_recordings"
|
||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH =
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
Environment.DIRECTORY_RECORDINGS
|
||||
else
|
||||
Environment.DIRECTORY_PODCASTS)
|
||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
||||
|
||||
// Don't use those values directly, use the constants from the instance.
|
||||
// Those values are only used inside the `SaveFolderTile`
|
||||
val SCOPED_MEDIA_CONTENT_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
val LEGACY_MEDIA_FOLDER = File(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
|
||||
// Parameters to be passed in descending order
|
||||
// Those parameters first try to concatenate without re-encoding
|
||||
// if that fails, it'll try several fallback methods
|
||||
// this is audio only
|
||||
val FFMPEG_PARAMETERS = arrayOf(
|
||||
" -c copy",
|
||||
" -acodec copy",
|
||||
" -c:a aac",
|
||||
" -c:a libmp3lame",
|
||||
" -c:a libopus",
|
||||
" -c:a libvorbis",
|
||||
)
|
||||
}
|
||||
}
|
602
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal file
602
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal file
@ -0,0 +1,602 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video.Media
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.reflect.KFunction4
|
||||
|
||||
|
||||
abstract class BatchesFolder(
|
||||
open val context: Context,
|
||||
open val type: BatchType,
|
||||
open val customFolder: DocumentFile? = null,
|
||||
open val subfolderName: String = ".recordings",
|
||||
) {
|
||||
abstract val concatenationFunction: KFunction4<Iterable<String>, String, String, (Int) -> Unit, CompletableDeferred<Unit>>
|
||||
abstract val ffmpegParameters: Array<String>
|
||||
abstract val scopedMediaContentUri: Uri
|
||||
abstract val legacyMediaFolder: File
|
||||
|
||||
val mediaPrefix
|
||||
get() = MEDIA_RECORDINGS_PREFIX + subfolderName.substring(1) + "-"
|
||||
|
||||
fun initFolders() {
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().mkdirs()
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
if (customFolder!!.findFile(subfolderName) == null) {
|
||||
customFolder!!.createDirectory(subfolderName)
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
// Scoped storage works fine on new Android versions,
|
||||
// we need to manually manage the folder on older versions
|
||||
if (!SUPPORTS_SCOPED_STORAGE) {
|
||||
legacyMediaFolder.mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInternalFolder(): File {
|
||||
return File(context.filesDir, subfolderName)
|
||||
}
|
||||
|
||||
fun getCustomDefinedFolder(): DocumentFile {
|
||||
return customFolder!!.findFile(subfolderName)!!
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
protected fun queryMediaContent(
|
||||
callback: (rawName: String, counter: Int, uri: Uri, cursor: Cursor) -> Any?,
|
||||
) {
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
null,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val rawName = cursor.getColumnIndex(Media.DISPLAY_NAME).let { id ->
|
||||
if (id == -1) null else cursor.getString(id)
|
||||
}
|
||||
|
||||
if (rawName.isNullOrBlank() || !rawName.startsWith(mediaPrefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val counter =
|
||||
rawName.substringAfter(mediaPrefix).substringBeforeLast(".").toIntOrNull()
|
||||
?: continue
|
||||
|
||||
val id = cursor.getColumnIndex(Media._ID).let { id ->
|
||||
if (id == -1) null else cursor.getString(id)
|
||||
}
|
||||
|
||||
if (id.isNullOrBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val uri = Uri.withAppendedPath(scopedMediaContentUri, id)
|
||||
|
||||
val result = callback(rawName, counter, uri, cursor)
|
||||
|
||||
if (result == false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBatchesForFFmpeg(): List<String> {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL ->
|
||||
((getInternalFolder()
|
||||
.listFiles()
|
||||
?.filter {
|
||||
it.nameWithoutExtension.toIntOrNull() != null
|
||||
}
|
||||
?.toList()
|
||||
?: emptyList()) as List<File>)
|
||||
.sortedBy {
|
||||
it.nameWithoutExtension.toInt()
|
||||
}
|
||||
.map { it.absolutePath }
|
||||
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder()
|
||||
.listFiles()
|
||||
.filter {
|
||||
it.name?.substringBeforeLast(".")?.toIntOrNull() != null
|
||||
}
|
||||
.sortedBy {
|
||||
it.name!!.substringBeforeLast(".").toInt()
|
||||
}
|
||||
.map {
|
||||
FFmpegKitConfig.getSafParameterForRead(
|
||||
context,
|
||||
it.uri,
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
val fileUris = mutableListOf<Pair<String, Uri>>()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryMediaContent { rawName, _, uri, _ ->
|
||||
fileUris.add(Pair(rawName, uri))
|
||||
}
|
||||
} else {
|
||||
legacyMediaFolder.listFiles()?.forEach {
|
||||
fileUris.add(Pair(it.name, it.toUri()))
|
||||
}
|
||||
}
|
||||
|
||||
fileUris
|
||||
.sortedBy {
|
||||
val name = it.first
|
||||
|
||||
return@sortedBy name
|
||||
.substring(mediaPrefix.length)
|
||||
.substringBeforeLast(".")
|
||||
.toInt()
|
||||
}
|
||||
.map { pair ->
|
||||
val uri = pair.second
|
||||
|
||||
FFmpegKitConfig.getSafParameterForRead(
|
||||
context,
|
||||
uri,
|
||||
)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getName(date: LocalDateTime, extension: String): String {
|
||||
val name = date
|
||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "_")
|
||||
|
||||
return "$name.$extension"
|
||||
}
|
||||
|
||||
fun asInternalGetOutputFile(fileName: String): File {
|
||||
return File(getInternalFolder(), fileName)
|
||||
}
|
||||
|
||||
fun asMediaGetLegacyFile(name: String): File = File(
|
||||
legacyMediaFolder,
|
||||
name
|
||||
).apply {
|
||||
createNewFile()
|
||||
}
|
||||
|
||||
fun checkIfOutputAlreadyExists(
|
||||
fileName: String,
|
||||
): Boolean {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> File(getInternalFolder(), fileName).exists()
|
||||
|
||||
BatchType.CUSTOM ->
|
||||
getCustomDefinedFolder().findFile(fileName)?.exists() ?: false
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
var exists = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryMediaContent { rawName, _, _, _ ->
|
||||
if (rawName == fileName) {
|
||||
exists = true
|
||||
return@queryMediaContent true
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
return exists
|
||||
} else {
|
||||
return File(
|
||||
legacyMediaFolder,
|
||||
fileName,
|
||||
).exists()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String
|
||||
|
||||
abstract fun cleanup()
|
||||
|
||||
suspend fun concatenate(
|
||||
recording: RecordingInformation,
|
||||
filenameFormat: AppSettings.FilenameFormat,
|
||||
disableCache: Boolean? = null,
|
||||
onNextParameterTry: (String) -> Unit = {},
|
||||
onProgress: (Float?) -> Unit = {},
|
||||
fileName: String,
|
||||
): String {
|
||||
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
|
||||
val date = recording.getStartDateForFilename(filenameFormat)
|
||||
|
||||
if (!disableCache && checkIfOutputAlreadyExists(fileName)
|
||||
) {
|
||||
return getOutputFileForFFmpeg(
|
||||
date = recording.recordingStart,
|
||||
extension = recording.fileExtension,
|
||||
fileName = fileName,
|
||||
)
|
||||
}
|
||||
|
||||
for (parameter in ffmpegParameters) {
|
||||
Log.i("Concatenation", "Trying parameter $parameter")
|
||||
onNextParameterTry(parameter)
|
||||
onProgress(null)
|
||||
|
||||
try {
|
||||
val fullTime = recording.getFullDuration().toFloat();
|
||||
val filePaths = getBatchesForFFmpeg()
|
||||
|
||||
val outputFile = getOutputFileForFFmpeg(
|
||||
date = date,
|
||||
extension = recording.fileExtension,
|
||||
fileName = fileName,
|
||||
)
|
||||
|
||||
concatenationFunction(
|
||||
filePaths,
|
||||
outputFile,
|
||||
parameter
|
||||
) { time ->
|
||||
// The progressbar for the conversion is calculated based on the
|
||||
// current time of the conversion and the total time of the batches.
|
||||
onProgress(time / fullTime)
|
||||
}.await()
|
||||
return outputFile
|
||||
} catch (e: MediaConverter.FFmpegException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw MediaConverter.FFmpegException("Failed to concatenate")
|
||||
}
|
||||
|
||||
fun exportFolderForSettings(): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> RECORDER_INTERNAL_SELECTED_VALUE
|
||||
BatchType.MEDIA -> RECORDER_MEDIA_SELECTED_VALUE
|
||||
BatchType.CUSTOM -> customFolder!!.uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecordings() {
|
||||
// Currently deletes all recordings.
|
||||
// This is fine, because we are saving the recordings
|
||||
// in a dedicated subfolder
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().deleteRecursively()
|
||||
|
||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete()
|
||||
?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// TODO: Also delete pending recordings
|
||||
// --> Doesn't seem to be possible :/
|
||||
context.contentResolver.delete(
|
||||
scopedMediaContentUri,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
)
|
||||
|
||||
} else {
|
||||
legacyMediaFolder.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRecordingsAvailable(): Boolean {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false
|
||||
|
||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty()
|
||||
?: false
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
var hasRecordings = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
hasRecordings = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasRecordings
|
||||
} else {
|
||||
return legacyMediaFolder.listFiles()?.isNotEmpty() ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecordings(range: LongRange) {
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach {
|
||||
val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach {
|
||||
val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val deletableNames = mutableListOf<String>()
|
||||
|
||||
queryMediaContent { rawName, counter, _, _ ->
|
||||
if (counter in range) {
|
||||
deletableNames.add(rawName)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.delete(
|
||||
scopedMediaContentUri,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} IN (${
|
||||
deletableNames.joinToString(
|
||||
","
|
||||
) { "'$it'" }
|
||||
})",
|
||||
null,
|
||||
)
|
||||
// This is unfortunate if the files can't be deleted, but let's just
|
||||
// ignore it since we can't do anything about it
|
||||
} catch (e: RuntimeException) {
|
||||
// Probably file not found
|
||||
e.printStackTrace()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Strange filename, should not happen
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
// TODO: Fix "would you like to try saving" -> Save button
|
||||
legacyMediaFolder.listFiles()?.forEach {
|
||||
val fileCounter =
|
||||
it.nameWithoutExtension.substring(mediaPrefix.length).toIntOrNull()
|
||||
?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfFolderIsAccessible(): Boolean {
|
||||
try {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> true
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead()
|
||||
BatchType.MEDIA -> {
|
||||
if (SUPPORTS_SCOPED_STORAGE) {
|
||||
return true
|
||||
}
|
||||
|
||||
return PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) &&
|
||||
PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error: NullPointerException) {
|
||||
error.printStackTrace()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun asInternalGetFile(counter: Long, fileExtension: String): File {
|
||||
return File(getInternalFolder(), "$counter.$fileExtension")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun getOrCreateMediaFile(
|
||||
name: String,
|
||||
mimeType: String,
|
||||
relativePath: String,
|
||||
): Uri {
|
||||
// Check if already exists
|
||||
var uri: Uri? = null
|
||||
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = '$name'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
// No need to check for the name since the query already did that
|
||||
val id = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||
|
||||
if (id == -1) {
|
||||
return@use
|
||||
}
|
||||
|
||||
uri = ContentUris.withAppendedId(
|
||||
scopedMediaContentUri,
|
||||
cursor.getLong(id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
try {
|
||||
// Create empty output file to be able to write to it
|
||||
uri = context.contentResolver.insert(
|
||||
scopedMediaContentUri,
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
name
|
||||
)
|
||||
put(
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
mimeType
|
||||
)
|
||||
|
||||
put(
|
||||
Media.RELATIVE_PATH,
|
||||
relativePath,
|
||||
)
|
||||
}
|
||||
)!!
|
||||
} catch (e: Exception) {
|
||||
Log.e("Media", "Failed to create file", e)
|
||||
}
|
||||
}
|
||||
|
||||
return uri!!
|
||||
}
|
||||
|
||||
fun getAvailableBytes(): Long? {
|
||||
if (type == BatchType.CUSTOM) {
|
||||
var fileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
try {
|
||||
fileDescriptor =
|
||||
context.contentResolver.openFileDescriptor(customFolder!!.uri, "r")!!
|
||||
val stats = Os.fstatvfs(fileDescriptor.fileDescriptor)
|
||||
|
||||
val available = stats.f_bavail * stats.f_bsize
|
||||
|
||||
runCatching {
|
||||
fileDescriptor.close()
|
||||
}
|
||||
|
||||
return available
|
||||
} catch (e: Exception) {
|
||||
runCatching {
|
||||
fileDescriptor?.close();
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
val storageManager = context.getSystemService(StorageManager::class.java) ?: return null
|
||||
val file = when (type) {
|
||||
BatchType.INTERNAL -> context.filesDir
|
||||
BatchType.MEDIA ->
|
||||
if (SUPPORTS_SCOPED_STORAGE)
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH),
|
||||
Media.EXTERNAL_CONTENT_URI.toString(),
|
||||
)
|
||||
else
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER),
|
||||
VideoBatchesFolder.MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
BatchType.CUSTOM -> throw IllegalArgumentException("This code should not be reachable")
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
storageManager.getAllocatableBytes(storageManager.getUuidForPath(file))
|
||||
} else {
|
||||
file.usableSpace;
|
||||
}
|
||||
}
|
||||
|
||||
enum class BatchType {
|
||||
INTERNAL,
|
||||
CUSTOM,
|
||||
MEDIA,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long {
|
||||
// 350 MiB sounds like a good default
|
||||
return 350 * 1024 * 1024
|
||||
}
|
||||
|
||||
fun canAccessFolder(context: Context, uri: Uri): Boolean {
|
||||
// This always returns false for some reason, let's just assume it's true
|
||||
return true
|
||||
/*
|
||||
return try {
|
||||
// Create temp file
|
||||
val docFile = DocumentFile.fromSingleUri(context, uri)!!
|
||||
|
||||
return docFile.canWrite().also {
|
||||
println("Can write? ${it}")
|
||||
} && docFile.canRead().also {
|
||||
println("Can read? ${it}")
|
||||
}
|
||||
} catch (error: RuntimeException) {
|
||||
error.printStackTrace()
|
||||
false
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
33
app/src/main/java/app/myzel394/alibi/helpers/Doctor.kt
Normal file
33
app/src/main/java/app/myzel394/alibi/helpers/Doctor.kt
Normal file
@ -0,0 +1,33 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
|
||||
data class Doctor(
|
||||
val context: Context
|
||||
) {
|
||||
fun checkIfFileSaverDialogIsAvailable(): Boolean {
|
||||
// Since API 30, we can't query other packages so easily anymore
|
||||
// (see https://developer.android.com/training/package-visibility).
|
||||
// For now, we assume the user has a file saver app installed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return true
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
val results =
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
||||
|
||||
if (results.isNotEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
183
app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt
Normal file
183
app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt
Normal file
@ -0,0 +1,183 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
// Abstract class for concatenating audio and video files
|
||||
// The concatenator runs in its own thread to avoid unresponsiveness.
|
||||
// You may be wondering why we simply not iterate over the FFMPEG_PARAMETERS
|
||||
// in this thread and then call each FFmpeg initiation just right after it?
|
||||
// The answer: It's easier; We don't have to deal with the `getBatchesForFFmpeg` function, because
|
||||
// the batches are only usable once and we if iterate in this thread over the FFMPEG_PARAMETERS
|
||||
// we would need to refetch the batches here, which is more messy.
|
||||
// This is okay, because in 99% of the time the first or second parameter will work,
|
||||
// and so there is no real performance loss.
|
||||
abstract class Concatenator(
|
||||
private val inputFiles: Iterable<String>,
|
||||
private val outputFile: String,
|
||||
private val extraCommand: String
|
||||
) : Thread() {
|
||||
abstract fun concatenate(): CompletableDeferred<Unit>
|
||||
|
||||
class FFmpegException(message: String) : Exception(message)
|
||||
}
|
||||
|
||||
data class AudioConcatenator(
|
||||
private val inputFiles: Iterable<String>,
|
||||
private val outputFile: String,
|
||||
private val extraCommand: String
|
||||
) : Concatenator(
|
||||
inputFiles,
|
||||
outputFile,
|
||||
extraCommand
|
||||
) {
|
||||
override fun concatenate(): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -i 'concat:$filePathsConcatenated'" +
|
||||
" -y" +
|
||||
extraCommand +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command
|
||||
) { session ->
|
||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
Log.i(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
||||
} else {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
return completer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MediaConverter {
|
||||
companion object {
|
||||
fun concatenateAudioFiles(
|
||||
inputFiles: Iterable<String>,
|
||||
outputFile: String,
|
||||
extraCommand: String = "",
|
||||
onProgress: (Int) -> Unit = { },
|
||||
): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -strict normal" +
|
||||
" -i 'concat:$filePathsConcatenated'" +
|
||||
extraCommand +
|
||||
" -y" +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
{ session ->
|
||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
Log.i(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
||||
} else {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
},
|
||||
{},
|
||||
{ statistics ->
|
||||
onProgress(statistics.time)
|
||||
}
|
||||
)
|
||||
|
||||
return completer
|
||||
}
|
||||
|
||||
private fun createTempFile(content: String): File {
|
||||
val id = UUID.randomUUID().toString()
|
||||
|
||||
return File.createTempFile(".temp-ffmpeg-files-$id", ".txt").apply {
|
||||
writeText(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun concatenateVideoFiles(
|
||||
inputFiles: Iterable<String>,
|
||||
outputFile: String,
|
||||
extraCommand: String = "",
|
||||
onProgress: (Int) -> Unit = { },
|
||||
): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val listFile = createTempFile(inputFiles.joinToString("\n") { "file '$it'" })
|
||||
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -safe 0" +
|
||||
" -strict normal" +
|
||||
" -f concat" +
|
||||
" -i ${listFile.absolutePath}" +
|
||||
extraCommand +
|
||||
" -y" +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
{ session ->
|
||||
runCatching {
|
||||
listFile.delete()
|
||||
}
|
||||
|
||||
if (ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
completer.complete(Unit)
|
||||
} else {
|
||||
Log.i(
|
||||
"Video Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(FFmpegException("Failed to concatenate videos"))
|
||||
}
|
||||
},
|
||||
{},
|
||||
{ statistics ->
|
||||
onProgress(statistics.time)
|
||||
}
|
||||
)
|
||||
|
||||
return completer
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegException(message: String) : Exception(message)
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles
|
||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class VideoBatchesFolder(
|
||||
override val context: Context,
|
||||
override val type: BatchType,
|
||||
override val customFolder: DocumentFile? = null,
|
||||
override val subfolderName: String = VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||
) : BatchesFolder(
|
||||
context,
|
||||
type,
|
||||
customFolder,
|
||||
subfolderName,
|
||||
) {
|
||||
override val concatenationFunction = ::concatenateVideoFiles
|
||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
||||
override val scopedMediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
override val legacyMediaFolder = File(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
private var customParcelFileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
override fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
||||
"video/${extension}",
|
||||
fileName,
|
||||
)!!).uri
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = fileName,
|
||||
mimeType = "video/$extension",
|
||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
||||
)
|
||||
|
||||
return FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
mediaUri
|
||||
)!!
|
||||
} else {
|
||||
val path = arrayOf(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_SUBFOLDER_NAME,
|
||||
fileName,
|
||||
).joinToString("/")
|
||||
return File(path)
|
||||
.apply {
|
||||
createNewFile()
|
||||
}.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
runCatching {
|
||||
customParcelFileDescriptor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun asCustomGetParcelFileDescriptor(
|
||||
counter: Long,
|
||||
fileExtension: String,
|
||||
): ParcelFileDescriptor {
|
||||
runCatching {
|
||||
customParcelFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val file =
|
||||
getCustomDefinedFolder().createFile(
|
||||
"video/$fileExtension",
|
||||
"$counter.$fileExtension"
|
||||
)!!
|
||||
val resolver = context.contentResolver.acquireContentProviderClient(file.uri)!!
|
||||
|
||||
resolver.use {
|
||||
customParcelFileDescriptor = it.openFile(file.uri, "w")!!
|
||||
|
||||
return customParcelFileDescriptor!!
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun asMediaGetScopedStorageContentValues(name: String) = ContentValues().apply {
|
||||
put(
|
||||
MediaStore.Video.Media.IS_PENDING,
|
||||
1
|
||||
)
|
||||
put(
|
||||
MediaStore.Video.Media.RELATIVE_PATH,
|
||||
SCOPED_STORAGE_RELATIVE_PATH,
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun viaInternalFolder(context: Context) = VideoBatchesFolder(context, BatchType.INTERNAL)
|
||||
|
||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
||||
VideoBatchesFolder(context, BatchType.CUSTOM, folder)
|
||||
|
||||
fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA)
|
||||
|
||||
fun importFromFolder(folder: String?, context: Context) = when (folder) {
|
||||
null -> viaInternalFolder(context)
|
||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
||||
else -> viaCustomFolder(
|
||||
context,
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(folder))!!
|
||||
)
|
||||
}
|
||||
|
||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_DCIM
|
||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.video_recordings"
|
||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH = Environment.DIRECTORY_DCIM
|
||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
||||
|
||||
// Parameters to be passed in descending order
|
||||
// Those parameters first try to concatenate without re-encoding
|
||||
// if that fails, it'll try several fallback methods
|
||||
val FFMPEG_PARAMETERS = arrayOf(
|
||||
" -c copy",
|
||||
" -c:v copy",
|
||||
" -c:v copy -c:a aac",
|
||||
" -c:v copy -c:a libmp3lame",
|
||||
" -c:v copy -c:a libopus",
|
||||
" -c:v copy -c:a libvorbis",
|
||||
" -c:a copy",
|
||||
// There's nothing else we can do to avoid re-encoding,
|
||||
// so we'll just have to re-encode the whole thing
|
||||
" -c:v libx264 -c:a copy",
|
||||
" -c:v libx264 -c:a aac",
|
||||
" -c:v libx265 -c:a aac",
|
||||
" -c:v libx264 -c:a libmp3lame",
|
||||
" -c:v libx264 -c:a libopus",
|
||||
" -c:v libx264 -c:a libvorbis",
|
||||
" -c:v libx265 -c:a copy",
|
||||
" -c:v libx265 -c:a aac",
|
||||
" -c:v libx265 -c:a libmp3lame",
|
||||
" -c:v libx265 -c:a libopus",
|
||||
" -c:v libx265 -c:a libvorbis",
|
||||
)
|
||||
}
|
||||
}
|
@ -1,46 +1,43 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.MediaRecorder.OnErrorListener
|
||||
import android.os.Build
|
||||
import java.lang.IllegalStateException
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.app.ServiceCompat
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
|
||||
class AudioRecorderService: IntervalRecorderService() {
|
||||
class AudioRecorderService :
|
||||
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
||||
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var amplitudes = mutableListOf<Int>()
|
||||
private set
|
||||
var amplitudesAmount = 1000
|
||||
|
||||
var selectedMicrophone: MicrophoneInfo? = null
|
||||
|
||||
var recorder: MediaRecorder? = null
|
||||
private set
|
||||
var onError: () -> Unit = {}
|
||||
|
||||
val filePath: String
|
||||
get() = "$folder/$counter.${settings!!.fileExtension}"
|
||||
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFile(filePath)
|
||||
setOutputFormat(settings!!.outputFormat)
|
||||
setAudioEncoder(settings!!.encoder)
|
||||
setAudioEncodingBitRate(settings!!.bitRate)
|
||||
setAudioSamplingRate(settings!!.samplingRate)
|
||||
setOnErrorListener(OnErrorListener { _, _, _ ->
|
||||
onError()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRecorder() {
|
||||
runCatching {
|
||||
recorder?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Callbacks
|
||||
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||
var onMicrophoneDisconnected: () -> Unit = {}
|
||||
var onMicrophoneReconnected: () -> Unit = {}
|
||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||
|
||||
override fun startNewCycle() {
|
||||
super.startNewCycle()
|
||||
@ -50,6 +47,7 @@ class AudioRecorderService: IntervalRecorderService() {
|
||||
}
|
||||
|
||||
resetRecorder()
|
||||
startAudioDevice()
|
||||
|
||||
try {
|
||||
recorder = newRecorder
|
||||
@ -59,21 +57,48 @@ class AudioRecorderService: IntervalRecorderService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
createAmplitudesTimer()
|
||||
registerMicrophoneListener()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
|
||||
resetRecorder()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
|
||||
override suspend fun stop() {
|
||||
resetRecorder()
|
||||
unregisterMicrophoneListener()
|
||||
|
||||
super.stop()
|
||||
}
|
||||
|
||||
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||
override fun resume() {
|
||||
super.resume()
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun getAmplitude(): Int {
|
||||
override fun startForegroundService() {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||
getNotificationHelper().buildStartingNotification(),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==== Amplitude related ====
|
||||
private fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||
|
||||
private fun getAmplitude(): Int {
|
||||
return try {
|
||||
recorder!!.maxAmplitude
|
||||
} catch (error: IllegalStateException) {
|
||||
@ -82,4 +107,209 @@ class AudioRecorderService: IntervalRecorderService() {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAmplitude() {
|
||||
if (state !== RecorderState.RECORDING) {
|
||||
return
|
||||
}
|
||||
|
||||
amplitudes.add(getAmplitude())
|
||||
onAmplitudeChange?.invoke(amplitudes)
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > getAmplitudeAmount()) {
|
||||
// Should be more efficient than dropping the elements, getting a new list
|
||||
// clearing old list and adding new elements to it
|
||||
repeat(amplitudes.size - getAmplitudeAmount()) {
|
||||
amplitudes.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
private fun createAmplitudesTimer() {
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
// ==== Audio device related ====
|
||||
|
||||
/// Tell Android to use the correct bluetooth microphone, if any selected
|
||||
private fun startAudioDevice() {
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
|
||||
} else {
|
||||
audioManger.startBluetoothSco()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAudioDevice() {
|
||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManger.clearCommunicationDevice()
|
||||
} else {
|
||||
audioManger.stopBluetoothSco()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameForMediaFile() =
|
||||
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
|
||||
|
||||
// ==== Actual recording related ====
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
val audioSettings = settings.audioRecorderSettings
|
||||
|
||||
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
||||
// and Redmi Buds 3 Pro:
|
||||
// - MIC: Uses the bottom microphone of the phone (17)
|
||||
// - CAMCORDER: Uses the top microphone of the phone (2)
|
||||
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
||||
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
|
||||
when (batchesFolder.type) {
|
||||
BatchesFolder.BatchType.INTERNAL -> {
|
||||
setOutputFile(
|
||||
batchesFolder.asInternalGetFile(
|
||||
counter,
|
||||
audioSettings.fileExtension
|
||||
).absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.CUSTOM -> {
|
||||
setOutputFile(
|
||||
batchesFolder.asCustomGetFileDescriptor(
|
||||
counter,
|
||||
audioSettings.fileExtension
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setOutputFile(
|
||||
batchesFolder.asMediaGetScopedStorageFileDescriptor(
|
||||
getNameForMediaFile(),
|
||||
"audio/${audioSettings.fileExtension}"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val name = getNameForMediaFile()
|
||||
val file = batchesFolder.asMediaGetLegacyFile(name)
|
||||
|
||||
setOutputFile(file.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOutputFormat(audioSettings.getOutputFormat())
|
||||
|
||||
setAudioEncoder(audioSettings.getEncoder())
|
||||
setAudioEncodingBitRate(audioSettings.bitRate)
|
||||
setAudioSamplingRate(audioSettings.getSamplingRate())
|
||||
setOnErrorListener(OnErrorListener { _, _, _ ->
|
||||
onError()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Microphone related ====
|
||||
private fun resetRecorder() {
|
||||
runCatching {
|
||||
recorder?.apply {
|
||||
stop()
|
||||
reset()
|
||||
release()
|
||||
}
|
||||
clearAudioDevice()
|
||||
batchesFolder.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
||||
selectedMicrophone = microphone
|
||||
onSelectedMicrophoneChange(microphone)
|
||||
|
||||
if (state == RecorderState.RECORDING) {
|
||||
startNewCycle()
|
||||
}
|
||||
}
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// We can't compare the ID, as it seems to be changing on each reconnect
|
||||
val newDevice = addedDevices?.find {
|
||||
it.productName == selectedMicrophone!!.deviceInfo.productName &&
|
||||
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
|
||||
it.type == selectedMicrophone!!.deviceInfo.type && (
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
it.address == selectedMicrophone!!.deviceInfo.address
|
||||
} else true
|
||||
)
|
||||
}
|
||||
if (newDevice != null) {
|
||||
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
|
||||
|
||||
onMicrophoneReconnected()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
||||
onMicrophoneDisconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerMicrophoneListener() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
audioManager.registerAudioDeviceCallback(
|
||||
audioDeviceCallback,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
}
|
||||
|
||||
private fun unregisterMicrophoneListener() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
}
|
||||
|
||||
// ==== Settings ====
|
||||
override fun getRecordingInformation() =
|
||||
RecordingInformation(
|
||||
folderPath = batchesFolder.exportFolderForSettings(),
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings.maxDuration,
|
||||
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
||||
fileExtension = settings.audioRecorderSettings.fileExtension,
|
||||
intervalDuration = settings.intervalDuration,
|
||||
type = RecordingInformation.Type.AUDIO,
|
||||
)
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class ExtraRecorderInformationService: RecorderService() {
|
||||
abstract fun getAmplitudeAmount(): Int
|
||||
abstract fun getAmplitude(): Int
|
||||
|
||||
var amplitudes = mutableListOf<Int>()
|
||||
private set
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||
|
||||
private fun updateAmplitude() {
|
||||
if (state !== RecorderState.RECORDING) {
|
||||
return
|
||||
}
|
||||
|
||||
amplitudes.add(getAmplitude())
|
||||
onAmplitudeChange?.invoke(amplitudes)
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > getAmplitudeAmount()) {
|
||||
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
|
||||
}
|
||||
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
private fun createAmplitudesTimer() {
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
}
|
@ -1,45 +1,45 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import app.myzel394.alibi.db.LastRecording
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executor
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||
private var job = SupervisorJob()
|
||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
protected var counter = 0
|
||||
abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
||||
RecorderService() {
|
||||
protected var counter = 0L
|
||||
private set
|
||||
protected lateinit var folder: File
|
||||
var settings: Settings? = null
|
||||
protected set
|
||||
|
||||
// Tracks the index of the currently locked file
|
||||
private var lockedIndex: Long? = null
|
||||
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private lateinit var cycleTimer: ScheduledExecutorService
|
||||
|
||||
fun createLastRecording(): LastRecording = LastRecording(
|
||||
folderPath = folder.absolutePath,
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings!!.maxDuration,
|
||||
fileExtension = settings!!.fileExtension,
|
||||
intervalDuration = settings!!.intervalDuration,
|
||||
forceExactMaxDuration = settings!!.forceExactMaxDuration,
|
||||
)
|
||||
abstract var batchesFolder: B
|
||||
|
||||
var onBatchesFolderNotAccessible: () -> Unit = {}
|
||||
|
||||
abstract fun getRecordingInformation(): I
|
||||
|
||||
// When saving the recording, the files should be locked.
|
||||
// This prevents the service from deleting the currently available files, so that
|
||||
// they can be safely used to save the recording.
|
||||
// Once finished, make sure to unlock the files using `unlockFiles`.
|
||||
fun lockFiles() {
|
||||
lockedIndex = counter
|
||||
}
|
||||
|
||||
// Unlocks and deletes the files that were locked using `lockFiles`.
|
||||
fun unlockFiles(cleanupFiles: Boolean = false) {
|
||||
if (cleanupFiles) {
|
||||
batchesFolder.deleteRecordings(0..<lockedIndex!!)
|
||||
}
|
||||
|
||||
lockedIndex = null
|
||||
}
|
||||
|
||||
// Make overrideable
|
||||
open fun startNewCycle() {
|
||||
@ -50,103 +50,56 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||
private fun createTimer() {
|
||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||
it.scheduleAtFixedRate(
|
||||
{
|
||||
startNewCycle()
|
||||
},
|
||||
::startNewCycle,
|
||||
0,
|
||||
settings!!.intervalDuration,
|
||||
settings.intervalDuration,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRandomFileFolder(): String {
|
||||
// uuid
|
||||
val folder = UUID.randomUUID().toString()
|
||||
|
||||
return "${externalCacheDir!!.absolutePath}/$folder"
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
folder = File(getRandomFileFolder())
|
||||
folder.mkdirs()
|
||||
batchesFolder.initFolders()
|
||||
|
||||
scope.launch {
|
||||
dataStore.data.collectLatest { preferenceSettings ->
|
||||
if (settings == null) {
|
||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
||||
if (!batchesFolder.checkIfFolderIsAccessible()) {
|
||||
onBatchesFolderNotAccessible()
|
||||
|
||||
createTimer()
|
||||
}
|
||||
}
|
||||
throw AvoidErrorDialogError()
|
||||
}
|
||||
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
cycleTimer.shutdown()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createTimer()
|
||||
|
||||
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
|
||||
// amplitudes
|
||||
super.resume()
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
override suspend fun stop() {
|
||||
cycleTimer.shutdown()
|
||||
batchesFolder.cleanup()
|
||||
super.stop()
|
||||
}
|
||||
|
||||
fun clearAllRecordings() {
|
||||
batchesFolder.deleteRecordings()
|
||||
}
|
||||
|
||||
private fun deleteOldRecordings() {
|
||||
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
||||
val earliestCounter = counter - timeMultiplier
|
||||
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
|
||||
|
||||
folder.listFiles()?.forEach { file ->
|
||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||
|
||||
if (fileCounter < earliestCounter) {
|
||||
file.delete()
|
||||
}
|
||||
if (earliestCounter <= 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
val bitRate: Int,
|
||||
val samplingRate: Int,
|
||||
val outputFormat: Int,
|
||||
val encoder: Int,
|
||||
) {
|
||||
val fileExtension: String
|
||||
get() = when(outputFormat) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||
return Settings(
|
||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||
bitRate = audioRecorderSettings.bitRate,
|
||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||
encoder = audioRecorderSettings.getEncoder(),
|
||||
maxDuration = audioRecorderSettings.maxDuration,
|
||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
batchesFolder.deleteRecordings(0..earliestCounter)
|
||||
}
|
||||
}
|
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# services
|
||||
|
||||
This folder contains all available services.
|
||||
|
||||
## VideoRecorderService
|
||||
|
||||
I found it a bit confusing on how to properly handle the services, so I made this diagram
|
||||
to help me understand it better. I hope it helps you too.
|
||||
|
||||

|
@ -0,0 +1,159 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import app.myzel394.alibi.MainActivity
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.NotificationSettings
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
|
||||
data class RecorderNotificationHelper(
|
||||
val context: Context,
|
||||
val details: NotificationDetails? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class NotificationDetails(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val icon: Int,
|
||||
val isOngoing: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun fromNotificationSettings(
|
||||
context: Context,
|
||||
settings: NotificationSettings,
|
||||
): NotificationDetails {
|
||||
return if (settings.preset == null) {
|
||||
NotificationDetails(
|
||||
settings.title,
|
||||
settings.message,
|
||||
settings.iconID,
|
||||
settings.showOngoing,
|
||||
)
|
||||
} else {
|
||||
NotificationDetails(
|
||||
context.getString(settings.preset.titleID),
|
||||
context.getString(settings.preset.messageID),
|
||||
settings.preset.iconID,
|
||||
settings.preset.showOngoing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNotificationChangeStateIntent(
|
||||
newState: RecorderState,
|
||||
requestCode: Int
|
||||
): PendingIntent {
|
||||
return PendingIntent.getService(
|
||||
context,
|
||||
requestCode,
|
||||
Intent(context, context::class.java).apply {
|
||||
action = "changeState"
|
||||
putExtra("newState", newState.name)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun getIconID(): Int = details?.icon ?: R.drawable.launcher_monochrome_noopacity
|
||||
|
||||
private fun createBaseNotification(): NotificationCompat.Builder {
|
||||
return NotificationCompat.Builder(
|
||||
context,
|
||||
NotificationHelper.RECORDER_CHANNEL_ID
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setSmallIcon(getIconID())
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setChronometerCountDown(false)
|
||||
}
|
||||
|
||||
private fun getStringForRecorder(audioRes: Int, videoRes: Int): String =
|
||||
when (context::class.java) {
|
||||
AudioRecorderService::class.java -> context.getString(audioRes)
|
||||
|
||||
VideoRecorderService::class.java -> context.getString(videoRes)
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun buildStartingNotification(): Notification {
|
||||
return createBaseNotification()
|
||||
.setContentTitle(
|
||||
getStringForRecorder(
|
||||
R.string.ui_audioRecorder_state_recording_title,
|
||||
R.string.ui_videoRecorder_state_recording_title,
|
||||
)
|
||||
)
|
||||
.setContentText(context.getString(R.string.ui_recorder_state_recording_description))
|
||||
.build()
|
||||
}
|
||||
|
||||
fun buildRecordingNotification(recordingTime: Long): Notification {
|
||||
return createBaseNotification()
|
||||
.setUsesChronometer(details?.isOngoing ?: true)
|
||||
.setOngoing(details?.isOngoing ?: true)
|
||||
.setShowWhen(details?.isOngoing ?: true)
|
||||
.setWhen(
|
||||
Date.from(
|
||||
Calendar
|
||||
.getInstance()
|
||||
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
|
||||
.toInstant()
|
||||
).time,
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_pause,
|
||||
context.getString(R.string.ui_recorder_action_pause_label),
|
||||
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
||||
)
|
||||
.setContentTitle(
|
||||
details?.title
|
||||
?: getStringForRecorder(
|
||||
R.string.ui_audioRecorder_state_recording_title,
|
||||
R.string.ui_videoRecorder_state_recording_title,
|
||||
)
|
||||
)
|
||||
.setContentText(
|
||||
details?.description
|
||||
?: context.getString(R.string.ui_recorder_state_recording_description)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun buildPausedNotification(start: LocalDateTime): Notification {
|
||||
return createBaseNotification()
|
||||
.setContentTitle(context.getString(R.string.ui_recorder_state_paused_title))
|
||||
.setContentText(context.getString(R.string.ui_recorder_state_paused_description))
|
||||
.setOngoing(false)
|
||||
.setUsesChronometer(false)
|
||||
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||
.addAction(
|
||||
R.drawable.ic_play,
|
||||
context.getString(R.string.ui_recorder_action_resume_label),
|
||||
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
@ -2,58 +2,117 @@ package app.myzel394.alibi.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import app.myzel394.alibi.MainActivity
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
abstract class RecorderService: Service() {
|
||||
abstract class RecorderService : LifecycleService() {
|
||||
private val binder = RecorderBinder()
|
||||
|
||||
private var isPaused: Boolean = false
|
||||
|
||||
lateinit var recordingStart: LocalDateTime
|
||||
private set
|
||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
||||
|
||||
var state = RecorderState.IDLE
|
||||
private set
|
||||
|
||||
var onStateChange: ((RecorderState) -> Unit)? = null
|
||||
var onError: () -> Unit = {}
|
||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||
|
||||
var recordingTime = 0L
|
||||
private set
|
||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||
|
||||
protected abstract fun start()
|
||||
protected abstract fun pause()
|
||||
protected abstract fun resume()
|
||||
protected abstract fun stop()
|
||||
protected open fun start() {
|
||||
createRecordingTimeTimer()
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? = binder
|
||||
protected open fun pause() {
|
||||
isPaused = true
|
||||
|
||||
recordingTimeTimer.shutdown()
|
||||
}
|
||||
|
||||
protected open fun resume() {
|
||||
createRecordingTimeTimer()
|
||||
}
|
||||
|
||||
protected open suspend fun stop() {
|
||||
recordingTimeTimer.shutdown()
|
||||
}
|
||||
|
||||
protected abstract fun startForegroundService()
|
||||
|
||||
fun startRecording() {
|
||||
recordingStart = LocalDateTime.now()
|
||||
|
||||
startForegroundService()
|
||||
changeState(RecorderState.RECORDING)
|
||||
|
||||
try {
|
||||
start()
|
||||
} catch (error: RuntimeException) {
|
||||
error.printStackTrace()
|
||||
|
||||
if (error !is AvoidErrorDialogError) {
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopRecording() {
|
||||
changeState(RecorderState.STOPPED)
|
||||
stop()
|
||||
}
|
||||
|
||||
fun pauseRecording() {
|
||||
changeState(RecorderState.PAUSED)
|
||||
}
|
||||
|
||||
fun resumeRecording() {
|
||||
changeState(RecorderState.RECORDING)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
NotificationManagerCompat.from(this)
|
||||
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
super.onBind(intent)
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
"init" -> {
|
||||
notificationDetails = intent.getStringExtra("notificationDetails")?.let {
|
||||
Json.decodeFromString(
|
||||
RecorderNotificationHelper.NotificationDetails.serializer(),
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"changeState" -> {
|
||||
val newState = intent.getStringExtra("newState")?.let {
|
||||
RecorderState.valueOf(it)
|
||||
} ?: RecorderState.IDLE
|
||||
} ?: RecorderState.STOPPED
|
||||
changeState(newState)
|
||||
}
|
||||
}
|
||||
@ -61,7 +120,7 @@ abstract class RecorderService: Service() {
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
inner class RecorderBinder: Binder() {
|
||||
inner class RecorderBinder : Binder() {
|
||||
fun getService(): RecorderService = this@RecorderService
|
||||
}
|
||||
|
||||
@ -69,16 +128,19 @@ abstract class RecorderService: Service() {
|
||||
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||
it.scheduleAtFixedRate(
|
||||
{
|
||||
recordingTime += 1000
|
||||
recordingTime += 1
|
||||
onRecordingTimeChange?.invoke(recordingTime)
|
||||
},
|
||||
0,
|
||||
1000,
|
||||
TimeUnit.MILLISECONDS
|
||||
1,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Used to change the state of the service
|
||||
// will internally call start() / pause() / resume() / stop()
|
||||
// Immediately after creating the service make sure to call `changeState(RecorderState.RECORDING)`
|
||||
@SuppressLint("MissingPermission")
|
||||
fun changeState(newState: RecorderState) {
|
||||
if (state == newState) {
|
||||
@ -91,151 +153,57 @@ abstract class RecorderService: Service() {
|
||||
if (isPaused) {
|
||||
resume()
|
||||
isPaused = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
// `start` is handled by `startRecording`
|
||||
}
|
||||
RecorderState.PAUSED -> {
|
||||
pause()
|
||||
isPaused = true
|
||||
}
|
||||
RecorderState.IDLE -> {
|
||||
stop()
|
||||
onDestroy()
|
||||
}
|
||||
|
||||
RecorderState.PAUSED -> pause()
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
when (newState) {
|
||||
RecorderState.RECORDING -> {
|
||||
createRecordingTimeTimer()
|
||||
}
|
||||
RecorderState.PAUSED, RecorderState.IDLE -> {
|
||||
recordingTimeTimer.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update notification
|
||||
if (
|
||||
arrayOf(
|
||||
RecorderState.RECORDING,
|
||||
RecorderState.PAUSED
|
||||
).contains(newState) &&
|
||||
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
){
|
||||
) {
|
||||
val notification = buildNotification()
|
||||
NotificationManagerCompat.from(this).notify(
|
||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||
notification
|
||||
)
|
||||
}
|
||||
|
||||
onStateChange?.invoke(newState)
|
||||
}
|
||||
|
||||
// Must be immediately called after creating the service!
|
||||
fun startRecording() {
|
||||
recordingStart = LocalDateTime.now()
|
||||
|
||||
val notification = buildStartNotification()
|
||||
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
|
||||
|
||||
// Start
|
||||
changeState(RecorderState.RECORDING)
|
||||
protected fun getNotificationHelper(): RecorderNotificationHelper {
|
||||
return RecorderNotificationHelper(this, notificationDetails)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
private fun buildNotification(): Notification {
|
||||
val notificationHelper = getNotificationHelper()
|
||||
|
||||
changeState(RecorderState.IDLE)
|
||||
return when (state) {
|
||||
RecorderState.RECORDING -> {
|
||||
notificationHelper.buildRecordingNotification(recordingTime)
|
||||
}
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
||||
stopSelf()
|
||||
RecorderState.PAUSED -> {
|
||||
notificationHelper.buildPausedNotification(recordingStart)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalStateException("Notification can't be built in state $state")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
|
||||
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
|
||||
.setSmallIcon(R.drawable.launcher_foreground)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
|
||||
private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
|
||||
return PendingIntent.getService(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(this, AudioRecorderService::class.java).apply {
|
||||
action = "changeState"
|
||||
putExtra("newState", newState.name)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification = when(state) {
|
||||
RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
|
||||
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
|
||||
.setSmallIcon(R.drawable.launcher_foreground)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setWhen(
|
||||
Date.from(
|
||||
Calendar
|
||||
.getInstance()
|
||||
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
|
||||
.toInstant()
|
||||
).time,
|
||||
)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(false)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_cancel,
|
||||
getString(R.string.ui_audioRecorder_action_delete_label),
|
||||
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_pause,
|
||||
getString(R.string.ui_audioRecorder_action_pause_label),
|
||||
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
||||
)
|
||||
.build()
|
||||
RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title))
|
||||
.setContentText(getString(R.string.ui_audioRecorder_state_paused_description))
|
||||
.setSmallIcon(R.drawable.launcher_foreground)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOngoing(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setUsesChronometer(false)
|
||||
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||
.setShowWhen(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.ui_audioRecorder_action_resume_label),
|
||||
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
||||
)
|
||||
.build()
|
||||
else -> throw IllegalStateException("Invalid state passed to `buildNotification()`")
|
||||
}
|
||||
// Throw this error if you show a dialog yourself.
|
||||
// This will prevent the service from showing their generic error dialog.
|
||||
class AvoidErrorDialogError : RuntimeException()
|
||||
}
|
@ -0,0 +1,347 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Range
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.TorchState
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileDescriptorOutputOptions
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.MediaStoreOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class VideoRecorderService :
|
||||
IntervalRecorderService<RecordingInformation, VideoBatchesFolder>() {
|
||||
override var batchesFolder = VideoBatchesFolder.viaInternalFolder(this)
|
||||
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
private var camera: Camera? = null
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var videoCapture: VideoCapture<Recorder>? = null
|
||||
private var activeRecording: Recording? = null
|
||||
|
||||
// Used to listen and check if the camera is available
|
||||
private var _cameraAvailableListener = CompletableDeferred<Unit>()
|
||||
private lateinit var _videoFinalizerListener: CompletableDeferred<Unit>;
|
||||
|
||||
// Absolute last completer that can be awaited to ensure that the camera is closed
|
||||
private var _cameraCloserListener = CompletableDeferred<Unit>()
|
||||
|
||||
private lateinit var selectedCamera: CameraSelector
|
||||
private var enableAudio by Delegates.notNull<Boolean>()
|
||||
|
||||
var onCameraControlAvailable = {}
|
||||
|
||||
var cameraControl: CameraControl? = null
|
||||
private set
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == "init") {
|
||||
selectedCamera = CameraSelector.Builder().requireLensFacing(
|
||||
intent.getIntExtra("cameraID", CameraSelector.LENS_FACING_BACK)
|
||||
).build()
|
||||
enableAudio = intent.getBooleanExtra("enableAudio", true)
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
scope.launch {
|
||||
openCamera()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
super.stop()
|
||||
|
||||
stopActiveRecording()
|
||||
|
||||
// Camera can only be closed after the recording has been finalized
|
||||
withTimeoutOrNull(CAMERA_CLOSE_TIMEOUT) {
|
||||
_videoFinalizerListener.await()
|
||||
}
|
||||
|
||||
closeCamera()
|
||||
|
||||
withTimeoutOrNull(CAMERA_CLOSE_TIMEOUT) {
|
||||
_cameraCloserListener.await()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
|
||||
stopActiveRecording()
|
||||
}
|
||||
|
||||
override fun startForegroundService() {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||
getNotificationHelper().buildStartingNotification(),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (enableAudio)
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
else
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun startNewCycle() {
|
||||
super.startNewCycle()
|
||||
|
||||
fun action() {
|
||||
stopActiveRecording()
|
||||
val newRecording = prepareVideoRecording()
|
||||
|
||||
_videoFinalizerListener = CompletableDeferred()
|
||||
|
||||
activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event ->
|
||||
if (event is VideoRecordEvent.Finalize && (this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED)) {
|
||||
_videoFinalizerListener.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_cameraAvailableListener.isCompleted) {
|
||||
action()
|
||||
} else {
|
||||
// Race condition of `startNewCycle` being called before `invokeOnCompletion`
|
||||
// has been called can be ignored, as the camera usually opens within 5 seconds
|
||||
// and the interval can't be set shorter than 10 seconds.
|
||||
_cameraAvailableListener.invokeOnCompletion {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Runs a function in the main thread
|
||||
private fun runOnMain(callback: () -> Unit) {
|
||||
val mainHandler = ContextCompat.getMainExecutor(this)
|
||||
|
||||
mainHandler.execute(callback)
|
||||
}
|
||||
|
||||
private fun buildRecorder() = Recorder.Builder()
|
||||
.setQualitySelector(
|
||||
settings.videoRecorderSettings.getQualitySelector()
|
||||
?: QualitySelector.from(Quality.HIGHEST)
|
||||
)
|
||||
.apply {
|
||||
if (settings.videoRecorderSettings.targetedVideoBitRate != null) {
|
||||
setTargetVideoEncodingBitRate(settings.videoRecorderSettings.targetedVideoBitRate!!)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildVideoCapture(recorder: Recorder) = VideoCapture.Builder(recorder)
|
||||
.apply {
|
||||
val frameRate = settings.videoRecorderSettings.targetFrameRate
|
||||
if (frameRate != null) {
|
||||
setTargetFrameRate(Range(frameRate, frameRate))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
// Open the camera.
|
||||
// Used to open it for a longer time, shouldn't be called when pausing / resuming.
|
||||
// This should only be called when starting a recording.
|
||||
private suspend fun openCamera() {
|
||||
cameraProvider = withContext(Dispatchers.IO) {
|
||||
ProcessCameraProvider.getInstance(this@VideoRecorderService).get()
|
||||
}
|
||||
|
||||
val recorder = buildRecorder()
|
||||
videoCapture = buildVideoCapture(recorder)
|
||||
|
||||
runOnMain {
|
||||
try {
|
||||
camera = cameraProvider!!.bindToLifecycle(
|
||||
this,
|
||||
selectedCamera,
|
||||
videoCapture
|
||||
)
|
||||
|
||||
cameraControl = CameraControl(camera!!).also {
|
||||
it.init()
|
||||
}
|
||||
onCameraControlAvailable()
|
||||
|
||||
_cameraAvailableListener.complete(Unit)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close the camera
|
||||
// Used to close it finally, shouldn't be called when pausing / resuming.
|
||||
// This should only be called after recording has finished.
|
||||
private fun closeCamera() {
|
||||
runOnMain {
|
||||
runCatching {
|
||||
cameraProvider?.unbindAll()
|
||||
}
|
||||
_cameraCloserListener.complete(Unit)
|
||||
|
||||
// Doesn't need to run on main thread, but
|
||||
// if it runs outside `runOnMain`, `cameraProvider` is already null
|
||||
// before it's unbound
|
||||
cameraProvider = null
|
||||
videoCapture = null
|
||||
camera = null
|
||||
}
|
||||
}
|
||||
|
||||
// `resume` override not needed as `startNewCycle` is called by `IntervalRecorderService`
|
||||
|
||||
private fun stopActiveRecording() {
|
||||
runCatching {
|
||||
activeRecording?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameForMediaFile() =
|
||||
"${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}"
|
||||
|
||||
@SuppressLint("MissingPermission", "NewApi")
|
||||
private fun prepareVideoRecording() =
|
||||
videoCapture!!.output
|
||||
.let {
|
||||
if (batchesFolder.type == BatchesFolder.BatchType.CUSTOM && SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
||||
it.prepareRecording(
|
||||
this,
|
||||
FileDescriptorOutputOptions.Builder(
|
||||
batchesFolder.asCustomGetParcelFileDescriptor(
|
||||
counter,
|
||||
settings.videoRecorderSettings.fileExtension
|
||||
)
|
||||
).build()
|
||||
)
|
||||
} else if (batchesFolder.type == BatchesFolder.BatchType.MEDIA) {
|
||||
if (SUPPORTS_SCOPED_STORAGE) {
|
||||
val name = getNameForMediaFile()
|
||||
|
||||
it.prepareRecording(
|
||||
this,
|
||||
MediaStoreOutputOptions
|
||||
.Builder(
|
||||
contentResolver,
|
||||
batchesFolder.scopedMediaContentUri,
|
||||
)
|
||||
.setContentValues(
|
||||
batchesFolder.asMediaGetScopedStorageContentValues(
|
||||
name
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
val name = getNameForMediaFile()
|
||||
|
||||
it.prepareRecording(
|
||||
this,
|
||||
FileOutputOptions
|
||||
.Builder(batchesFolder.asMediaGetLegacyFile(name))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
it.prepareRecording(
|
||||
this,
|
||||
FileOutputOptions.Builder(
|
||||
batchesFolder.asInternalGetFile(
|
||||
counter,
|
||||
settings.videoRecorderSettings.fileExtension
|
||||
).apply {
|
||||
createNewFile()
|
||||
}
|
||||
).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
.run {
|
||||
if (enableAudio) {
|
||||
return@run withAudioEnabled()
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
override fun getRecordingInformation() =
|
||||
RecordingInformation(
|
||||
folderPath = batchesFolder.exportFolderForSettings(),
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings.maxDuration,
|
||||
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
||||
fileExtension = settings.videoRecorderSettings.fileExtension,
|
||||
intervalDuration = settings.intervalDuration,
|
||||
type = RecordingInformation.Type.VIDEO,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val CAMERA_CLOSE_TIMEOUT = 20000L
|
||||
}
|
||||
|
||||
class CameraControl(
|
||||
val camera: Camera,
|
||||
// Save state for optimistic updates
|
||||
var torchEnabled: Boolean = false,
|
||||
) {
|
||||
fun init() {
|
||||
torchEnabled = camera.cameraInfo.torchState.value == TorchState.ON
|
||||
}
|
||||
|
||||
fun enableTorch() {
|
||||
torchEnabled = true
|
||||
camera.cameraControl.enableTorch(true)
|
||||
}
|
||||
|
||||
fun disableTorch() {
|
||||
torchEnabled = false
|
||||
camera.cameraControl.enableTorch(false)
|
||||
}
|
||||
|
||||
fun isHardwareTorchReallyEnabled(): Boolean {
|
||||
return camera.cameraInfo.torchState.value == TorchState.ON
|
||||
}
|
||||
|
||||
fun hasTorchAvailable() = camera.cameraInfo.hasFlashUnit()
|
||||
}
|
||||
}
|
1
app/src/main/java/app/myzel394/alibi/services/model.svg
Normal file
1
app/src/main/java/app/myzel394/alibi/services/model.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 13 KiB |
151
app/src/main/java/app/myzel394/alibi/ui/AsLockedApp.kt
Normal file
151
app/src/main/java/app/myzel394/alibi/ui/AsLockedApp.kt
Normal file
@ -0,0 +1,151 @@
|
||||
package app.myzel394.alibi.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.helpers.AppLockHelper
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// After this amount, close the app
|
||||
const val MAX_TRIES = 5
|
||||
|
||||
// Makes sure the app needs to be unlocked first, if app lock is enabled
|
||||
@Composable
|
||||
fun AsLockedApp(
|
||||
content: (@Composable () -> Unit),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val settings = context
|
||||
.dataStore
|
||||
.data
|
||||
.collectAsState(initial = null)
|
||||
.value ?: return
|
||||
|
||||
// -1 = Unlocked, any other value = locked
|
||||
var tries by remember {
|
||||
mutableIntStateOf(
|
||||
if (settings.isAppLockEnabled()) 0 else -1
|
||||
)
|
||||
}
|
||||
|
||||
if (tries == -1) {
|
||||
return content()
|
||||
}
|
||||
|
||||
val title = stringResource(R.string.identityVerificationRequired_title)
|
||||
val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
|
||||
|
||||
fun openAuthentication() {
|
||||
if (tries >= MAX_TRIES) {
|
||||
AppLockHelper.closeApp(context)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val successful = AppLockHelper.authenticate(
|
||||
context,
|
||||
title,
|
||||
subtitle,
|
||||
).await()
|
||||
|
||||
if (successful) {
|
||||
tries = -1
|
||||
return@launch
|
||||
}
|
||||
|
||||
tries++
|
||||
|
||||
if (tries >= MAX_TRIES) {
|
||||
AppLockHelper.closeApp(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(settings.isAppLockEnabled()) {
|
||||
if (settings.isAppLockEnabled()) {
|
||||
openAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box {}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.ui_locked_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
ElevatedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||
onClick = ::openAuthentication,
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = stringResource(R.string.ui_locked_unlocked),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,80 @@
|
||||
package app.myzel394.alibi.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.util.Base64
|
||||
|
||||
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
||||
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
|
||||
|
||||
val SHEET_BOTTOM_OFFSET = 24.dp
|
||||
val MAX_AMPLITUDE = 20000
|
||||
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
|
||||
val MEDIA_SUBFOLDER_NAME = "alibi"
|
||||
|
||||
val SUPPORTS_SCOPED_STORAGE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
val SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
val MEDIA_RECORDINGS_PREFIX = "alibi-recording-"
|
||||
val RECORDER_MEDIA_SELECTED_VALUE = "_'media"
|
||||
val RECORDER_INTERNAL_SELECTED_VALUE = "_'internal"
|
||||
|
||||
val VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME = ".video_recordings"
|
||||
val AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME = ".audio_recordings"
|
||||
|
||||
// You are not allowed to change the constants below.
|
||||
// If you do so, you will be blocked on GitHub.
|
||||
const val REPO_URL = "https://github.com/Myzel394/Alibi"
|
||||
const val TRANSLATION_HELP_URL = "https://crowdin.com/project/alibi"
|
||||
const val GITHUB_SPONSORS_URL = "https://github.com/sponsors/Myzel394"
|
||||
const val PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEZTfvnhYJKwYBBAHaRw8BAQdAi2AiLsTaBoLhnQtY5vi3xBU/H428wbNfBSe+
|
||||
2dhz3r60Jk15emVsMzk0IDxnaXRodWIuN2Eyb3BAc2ltcGxlbG9naW4uY28+iJkE
|
||||
ExYKAEEWIQR9BS8nNHwqrNgV0B3NE0dCwel5WQUCZTfvngIbAwUJEswDAAULCQgH
|
||||
AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRDNE0dCwel5WcS8AQCf9g6eEaut1suW
|
||||
l6jCLIg3b1nWLckmLJaonM6PruUtigEAmVnFOxMpOZEIcILT8CD2Riy+IVN9gTNH
|
||||
qOHnaFsu8AK4OARlN++eEgorBgEEAZdVAQUBAQdAe4ffDtRundKH9kam746i2TBu
|
||||
P9sfb3QVi5QqfK+bek8DAQgHiH4EGBYKACYWIQR9BS8nNHwqrNgV0B3NE0dCwel5
|
||||
WQUCZTfvngIbDAUJEswDAAAKCRDNE0dCwel5WWwSAQDj4ZAl6bSqwbcptEMYQaPM
|
||||
MMhMafm446MjkhQioeXw+wEAzA8mS6RBx7IZvu1dirmFHXOEYJclwjyQhNs4uEjq
|
||||
/Ak=
|
||||
=ICHe
|
||||
-----END PGP PUBLIC KEY BLOCK-----"""
|
||||
const val PUBLIC_KEY_FINGERPRINT = "7D05 2F27 347C 2AAC D815 D01D CD13 4742 C1E9 7959"
|
||||
val CRYPTO_DONATIONS = mapOf(
|
||||
"Bitcoin" to "bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6",
|
||||
"Bitcoin Cash" to "qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l",
|
||||
"Ethereum" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
||||
"Tether USD" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
||||
"Monero" to "83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8",
|
||||
"Zcash" to "t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx",
|
||||
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
||||
"Dash" to "XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU",
|
||||
"Avalanche" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
|
||||
"XRP" to "rNpfDm8UwDTumCebchBadjVW2FEPteFgNg",
|
||||
"Solana" to "2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb",
|
||||
"ADA" to "addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc",
|
||||
"Dogecoin" to "DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD",
|
||||
"Tron" to "THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb",
|
||||
"Polkadot" to "1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR",
|
||||
"Cosmos" to "cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz",
|
||||
"Algorand" to "QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI",
|
||||
"Tezos" to "tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi",
|
||||
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
||||
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
|
||||
)
|
||||
|
||||
// Base64encoding these values so that bots can't easily scrape them.
|
||||
val b64d = Base64.getDecoder()
|
||||
val CONTACT_METHODS = mapOf<String, String>(
|
||||
"E-Mail" to String(b64d.decode("Z2" + "9vZ2xlLXBsYX" + "k" + "uMjlrMWFAYWxlZWFzL" + "mNvbQo=")).trim(),
|
||||
"GitHub" to String(
|
||||
b64d.decode(
|
||||
"aHR" +
|
||||
"0cHM6Ly9n" + "a" + "XRodWIuY29t" + "L015emVsMzk0L2NvbnRhY3QtbWUK"
|
||||
)
|
||||
).trim(),
|
||||
"Mastodon" to String(b64d.decode("T" + "X" + "l6Z" + "WwzOTRAbWFzdG9kb24uc29" + "jaWFsCg" + "==")).trim(),
|
||||
"Reddit" to "https://reddit.com/u/Myzel394"
|
||||
)
|
||||
|
84
app/src/main/java/app/myzel394/alibi/ui/LockedAppHandlers.kt
Normal file
84
app/src/main/java/app/myzel394/alibi/ui/LockedAppHandlers.kt
Normal file
@ -0,0 +1,84 @@
|
||||
package app.myzel394.alibi.ui
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.Doctor
|
||||
|
||||
// Handlers that can safely be run when the app is locked (biometric authentication required)
|
||||
@Composable
|
||||
fun LockedAppHandlers() {
|
||||
val context = LocalContext.current
|
||||
val settings = context
|
||||
.dataStore
|
||||
.data
|
||||
.collectAsState(initial = null)
|
||||
.value ?: return
|
||||
|
||||
LaunchedEffect(settings.theme) {
|
||||
if (!SUPPORTS_DARK_MODE_NATIVELY) {
|
||||
val currentValue = AppCompatDelegate.getDefaultNightMode()
|
||||
|
||||
if (settings.theme == AppSettings.Theme.LIGHT && currentValue != AppCompatDelegate.MODE_NIGHT_NO) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
} else if (settings.theme == AppSettings.Theme.DARK && currentValue != AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showFileSaverUnavailableDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val doctor = Doctor(context)
|
||||
|
||||
if (!doctor.checkIfFileSaverDialogIsAvailable()) {
|
||||
showFileSaverUnavailableDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
if (showFileSaverUnavailableDialog) {
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onDismissRequest = {
|
||||
showFileSaverUnavailableDialog = false
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_severeError_fileSaverUnavailable_title))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ui_severeError_fileSaverUnavailable_text))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showFileSaverUnavailableDialog = false
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@ -5,34 +5,37 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.LastRecording
|
||||
import app.myzel394.alibi.ui.enums.Screen
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.screens.AudioRecorder
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.screens.AboutScreen
|
||||
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
|
||||
import app.myzel394.alibi.ui.screens.RecorderScreen
|
||||
import app.myzel394.alibi.ui.screens.SettingsScreen
|
||||
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
||||
|
||||
const val SCALE_IN = 1.25f
|
||||
const val DEBUG_SKIP_WELCOME = false;
|
||||
|
||||
@Composable
|
||||
fun Navigation(
|
||||
audioRecorder: AudioRecorderModel = viewModel()
|
||||
audioRecorder: AudioRecorderModel = viewModel(),
|
||||
videoRecorder: VideoRecorderModel = viewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
@ -44,9 +47,11 @@ fun Navigation(
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
audioRecorder.bindToService(context)
|
||||
videoRecorder.bindToService(context)
|
||||
|
||||
onDispose {
|
||||
audioRecorder.unbindFromService(context)
|
||||
videoRecorder.unbindFromService(context)
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,10 +59,18 @@ fun Navigation(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
navController = navController,
|
||||
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
|
||||
startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else Screen.Welcome.route,
|
||||
) {
|
||||
composable(Screen.Welcome.route) {
|
||||
WelcomeScreen(navController = navController)
|
||||
WelcomeScreen(
|
||||
onNavigateToAudioRecorderScreen = {
|
||||
val mainHandler = ContextCompat.getMainExecutor(context)
|
||||
|
||||
mainHandler.execute {
|
||||
navController.navigate(Screen.AudioRecorder.route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.AudioRecorder.route,
|
||||
@ -71,9 +84,13 @@ fun Navigation(
|
||||
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
||||
}
|
||||
) {
|
||||
AudioRecorder(
|
||||
navController = navController,
|
||||
RecorderScreen(
|
||||
onNavigateToSettingsScreen = {
|
||||
navController.navigate(Screen.Settings.route)
|
||||
},
|
||||
audioRecorder = audioRecorder,
|
||||
videoRecorder = videoRecorder,
|
||||
settings = settings,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@ -86,8 +103,43 @@ fun Navigation(
|
||||
}
|
||||
) {
|
||||
SettingsScreen(
|
||||
navController = navController,
|
||||
onBackNavigate = navController::popBackStack,
|
||||
onNavigateToCustomRecordingNotifications = {
|
||||
navController.navigate(Screen.CustomRecordingNotifications.route)
|
||||
},
|
||||
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
|
||||
audioRecorder = audioRecorder,
|
||||
videoRecorder = videoRecorder,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.CustomRecordingNotifications.route,
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it -> it / 2 }
|
||||
) + fadeIn()
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it -> it / 2 }
|
||||
) + fadeOut(tween(150))
|
||||
}
|
||||
) {
|
||||
CustomRecordingNotificationsScreen(
|
||||
onBackNavigate = navController::popBackStack
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Screen.About.route,
|
||||
enterTransition = {
|
||||
scaleIn()
|
||||
},
|
||||
exitTransition = {
|
||||
scaleOut() + fadeOut(tween(150))
|
||||
}
|
||||
) {
|
||||
AboutScreen(
|
||||
onBackNavigate = navController::popBackStack,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
14
app/src/main/java/app/myzel394/alibi/ui/README.md
Normal file
14
app/src/main/java/app/myzel394/alibi/ui/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# ui
|
||||
|
||||
This folder contains all user interfaces. The folder is structured as follows:
|
||||
|
||||
* `components`: contains all reusable components
|
||||
* `atoms`, `molecules`, `organisms`, `pages`: contains components that are generic and can be
|
||||
reused in different contexts
|
||||
* `<name>Screen/{atoms,molecules,organisms,pages}`: contains components that are specific to a
|
||||
screen
|
||||
* `screens`: contains all screens. Screens are composed of components from the `components` folder
|
||||
* `models`: contains view models used by the screens
|
||||
* `utils`: contains general utility functions
|
||||
|
||||
The root Kotlin files are used for the general setup of the UI.
|
@ -0,0 +1,184 @@
|
||||
package app.myzel394.alibi.ui.components.AboutScreen.atoms
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.CurrencyFranc
|
||||
import androidx.compose.material.icons.filled.CurrencyLira
|
||||
import androidx.compose.material.icons.filled.CurrencyPound
|
||||
import androidx.compose.material.icons.filled.CurrencyRuble
|
||||
import androidx.compose.material.icons.filled.CurrencyRupee
|
||||
import androidx.compose.material.icons.filled.CurrencyYen
|
||||
import androidx.compose.material.icons.filled.CurrencyYuan
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.CRYPTO_DONATIONS
|
||||
import app.myzel394.alibi.ui.GITHUB_SPONSORS_URL
|
||||
import app.myzel394.alibi.ui.PUBLIC_KEY
|
||||
|
||||
@Composable
|
||||
fun DonationsTile() {
|
||||
var donationsOpened by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val label = stringResource(R.string.ui_about_contribute_donatation)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
donationsOpened = !donationsOpened
|
||||
}
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
listOf(
|
||||
Icons.Default.CurrencyBitcoin,
|
||||
Icons.Default.CurrencyFranc,
|
||||
Icons.Default.CurrencyLira,
|
||||
Icons.Default.CurrencyPound,
|
||||
Icons.Default.CurrencyRuble,
|
||||
Icons.Default.CurrencyRupee,
|
||||
Icons.Default.CurrencyYen,
|
||||
Icons.Default.CurrencyYuan,
|
||||
).asSequence().shuffled().first(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_about_contribute_donatation),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
val rotation by animateFloatAsState(
|
||||
if (donationsOpened) -180f else 0f,
|
||||
label = "iconRotation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize.times(1.2f))
|
||||
.rotate(rotation)
|
||||
)
|
||||
}
|
||||
|
||||
val clipboardManager =
|
||||
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = donationsOpened,
|
||||
enter = expandVertically(),
|
||||
) {
|
||||
Column {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
uriHandler.openUri(GITHUB_SPONSORS_URL)
|
||||
},
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_github),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
|
||||
)
|
||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
stringResource(R.string.ui_about_contribute_donation_githubSponsors)
|
||||
)
|
||||
}
|
||||
for (crypto in CRYPTO_DONATIONS) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable {
|
||||
val clip = ClipData.newPlainText("text", crypto.value)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
}
|
||||
.padding(16.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
crypto.key,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
crypto.value,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
package app.myzel394.alibi.ui.components.AboutScreen.atoms
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.PUBLIC_KEY_FINGERPRINT
|
||||
import app.myzel394.alibi.ui.PUBLIC_KEY
|
||||
|
||||
@Composable
|
||||
fun GPGKeyOverview() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.ui_about_gpg_key_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
val clipboardManager =
|
||||
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
Text(
|
||||
PUBLIC_KEY_FINGERPRINT,
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.ui_about_gpg_key_copy))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun SaveRecordingButton(
|
||||
modifier: Modifier = Modifier,
|
||||
service: RecorderService,
|
||||
onSaveFile: (File) -> Unit,
|
||||
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isProcessingAudio by remember { mutableStateOf(false) }
|
||||
|
||||
if (isProcessingAudio)
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Memory,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
LinearProgressIndicator()
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.then(modifier),
|
||||
onClick = {
|
||||
isProcessingAudio = true
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
} catch (error: Exception) {
|
||||
Log.getStackTraceString(error)
|
||||
} finally {
|
||||
isProcessingAudio = false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
}
|
@ -1,219 +0,0 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LargeFloatingActionButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||
import app.myzel394.alibi.ui.utils.formatDuration
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
@Composable
|
||||
fun RecordingStatus(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
now = LocalDateTime.now()
|
||||
delay(900)
|
||||
}
|
||||
}
|
||||
|
||||
// Only show animation when the recording has just started
|
||||
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
|
||||
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
||||
LaunchedEffect(Unit) {
|
||||
progressVisible = true
|
||||
}
|
||||
|
||||
KeepScreenOn()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box {}
|
||||
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Pulsating {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Red)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = formatDuration(audioRecorder.recordingTime!!),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AnimatedVisibility(
|
||||
visible = progressVisible,
|
||||
enter = expandHorizontally(
|
||||
tween(1000)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = audioRecorder.progress,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
ConfirmDeletionDialog(
|
||||
onDismiss = {
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
audioRecorder.stopRecording(context, saveAsLastRecording = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = {
|
||||
showDeleteDialog = true
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
|
||||
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
|
||||
LargeFloatingActionButton(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
|
||||
},
|
||||
onClick = {
|
||||
if (audioRecorder.isPaused) {
|
||||
audioRecorder.resumeRecording()
|
||||
} else {
|
||||
audioRecorder.pauseRecording()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.alpha(alpha)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = {
|
||||
runCatching {
|
||||
audioRecorder.stopRecording(context)
|
||||
}
|
||||
audioRecorder.onRecordingSave()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.ui_audioRecorder_action_save_label))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun StartRecording(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.RECORD_AUDIO,
|
||||
icon = Icons.Default.Mic,
|
||||
onPermissionAvailable = {
|
||||
audioRecorder.startRecording(context)
|
||||
},
|
||||
) { trigger ->
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
|
||||
Button(
|
||||
onClick = {
|
||||
trigger()
|
||||
},
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.size(200.dp)
|
||||
.clip(shape = CircleShape),
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val settings = LocalContext
|
||||
.current
|
||||
.dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_action_start_description, settings.audioRecorderSettings.maxDuration / 1000 / 60),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
val label = stringResource(
|
||||
R.string.ui_audioRecorder_action_saveOldRecording_label,
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
onClick = {
|
||||
audioRecorder.stopRecording(context)
|
||||
audioRecorder.onRecordingSave()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowRightAlt
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.utils.openNotificationsSettings
|
||||
|
||||
@Composable
|
||||
fun LandingElement(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenEditor: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp, vertical = 64.dp)
|
||||
.then(modifier),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box() {}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_custom_recording_notifications_blob),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.width(512.dp)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_customNotifications_landing_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_customNotifications_landing_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
FilledTonalButton(onClick = onOpenEditor) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_settings_customNotifications_landing_getStarted
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = context::openNotificationsSettings,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.db.NotificationSettings
|
||||
|
||||
@Composable
|
||||
fun NotificationPresetSelect(
|
||||
modifier: Modifier = Modifier,
|
||||
preset: NotificationSettings.Preset
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.then(modifier)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
PreviewIcon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(id = preset.iconID),
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(preset.titleID),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(preset.messageID),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun PreviewIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
painter: Painter,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.then(modifier)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondary)
|
||||
.padding(2.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary),
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.NotificationSettings
|
||||
|
||||
class NotificationViewModel : ViewModel() {
|
||||
// We want to show the actual translated strings of the preset
|
||||
// in the preview but don't want to save them to the database
|
||||
// because they should be retrieved in the notification itself.
|
||||
// Thus we save whether the preset has been changed by the user
|
||||
private var _presetChanged = false
|
||||
|
||||
private var _title = mutableStateOf("")
|
||||
val title: String
|
||||
get() = _title.value
|
||||
private var _description = mutableStateOf("")
|
||||
val description: String
|
||||
get() = _description.value
|
||||
|
||||
var showOngoing: Boolean by mutableStateOf(true)
|
||||
var icon: Int by mutableIntStateOf(R.drawable.launcher_monochrome_noopacity)
|
||||
|
||||
// `preset` can't be used as a variable name here because
|
||||
// the compiler throws a strange error then
|
||||
var notificationPreset: NotificationSettings.Preset? by mutableStateOf(null)
|
||||
|
||||
private var _hasBeenInitialized = false;
|
||||
|
||||
|
||||
fun setPreset(title: String, description: String, preset: NotificationSettings.Preset) {
|
||||
_presetChanged = false
|
||||
|
||||
_title.value = title
|
||||
_description.value = description
|
||||
showOngoing = preset.showOngoing
|
||||
icon = preset.iconID
|
||||
this.notificationPreset = preset
|
||||
}
|
||||
|
||||
fun setTitle(title: String) {
|
||||
_presetChanged = true
|
||||
_title.value = title
|
||||
}
|
||||
|
||||
fun setDescription(description: String) {
|
||||
_presetChanged = true
|
||||
_description.value = description
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
title: String,
|
||||
description: String,
|
||||
showOngoing: Boolean = true,
|
||||
icon: Int = R.drawable.launcher_monochrome_noopacity,
|
||||
) {
|
||||
_title.value = title
|
||||
_description.value = description
|
||||
this.showOngoing = showOngoing
|
||||
this.icon = icon
|
||||
_hasBeenInitialized = true
|
||||
}
|
||||
|
||||
fun asNotificationSettings(): NotificationSettings {
|
||||
return if (!_presetChanged && notificationPreset != null) {
|
||||
NotificationSettings.fromPreset(notificationPreset!!)
|
||||
} else {
|
||||
NotificationSettings(
|
||||
title = title,
|
||||
message = description,
|
||||
iconID = icon,
|
||||
showOngoing = showOngoing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,171 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
|
||||
import app.myzel394.alibi.ui.effects.rememberForceUpdate
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Composable
|
||||
fun EditNotificationInput(
|
||||
modifier: Modifier = Modifier,
|
||||
showOngoing: Boolean,
|
||||
title: String,
|
||||
description: String,
|
||||
icon: Painter,
|
||||
onShowOngoingChange: (Boolean) -> Unit,
|
||||
onTitleChange: (String) -> Unit,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
onIconChange: (Int) -> Unit,
|
||||
) {
|
||||
var ongoingStartTime by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
|
||||
val secondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
|
||||
LaunchedEffect(showOngoing) {
|
||||
if (showOngoing) {
|
||||
ongoingStartTime = LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
|
||||
.padding(16.dp)
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val headlineSize = 22.dp
|
||||
|
||||
PreviewIcon(
|
||||
modifier = Modifier.size(headlineSize),
|
||||
painter = icon,
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.height(headlineSize),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = secondaryColor,
|
||||
)
|
||||
if (showOngoing) {
|
||||
Icon(
|
||||
Icons.Default.Circle,
|
||||
contentDescription = null,
|
||||
tint = secondaryColor,
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
)
|
||||
|
||||
val fakeAlpha = rememberForceUpdate()
|
||||
val formattedTime = {
|
||||
val difference =
|
||||
Duration.between(
|
||||
ongoingStartTime,
|
||||
LocalDateTime.now(),
|
||||
)
|
||||
val minutes = difference.toMinutes()
|
||||
val seconds = difference.minusMinutes(minutes).seconds
|
||||
|
||||
"${if (minutes < 10) "0$minutes" else minutes}:${if (seconds < 10) "0$seconds" else seconds}"
|
||||
}
|
||||
Text(
|
||||
formattedTime(),
|
||||
modifier = Modifier.alpha(fakeAlpha),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = secondaryColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = title,
|
||||
onValueChange = onTitleChange,
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = description,
|
||||
onValueChange = onDescriptionChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_delete_label),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_pause_label),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.NotificationSettings
|
||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NotificationPresetsRoulette(
|
||||
onClick: (String, String, NotificationSettings.Preset) -> Unit,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
state = state,
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state)
|
||||
) {
|
||||
items(NotificationSettings.PRESETS.size) {
|
||||
val preset = NotificationSettings.PRESETS[it]
|
||||
|
||||
val label = stringResource(
|
||||
R.string.ui_settings_customNotifications_preset_apply_label,
|
||||
stringResource(preset.titleID)
|
||||
)
|
||||
val presetTitle = stringResource(preset.titleID)
|
||||
val presetDescription = stringResource(preset.messageID)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.width(
|
||||
LocalConfiguration.current.screenWidthDp.dp,
|
||||
)
|
||||
) {
|
||||
NotificationPresetSelect(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(.95f)
|
||||
.align(Alignment.Center)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
onClick(
|
||||
presetTitle,
|
||||
presetDescription,
|
||||
preset,
|
||||
)
|
||||
},
|
||||
preset = preset,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,203 @@
|
||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.NotificationSettings
|
||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models.NotificationViewModel
|
||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput
|
||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.NotificationPresetsRoulette
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
|
||||
val HORIZONTAL_PADDING = 16.dp;
|
||||
|
||||
@Composable
|
||||
fun NotificationEditor(
|
||||
modifier: Modifier = Modifier,
|
||||
notificationModel: NotificationViewModel = viewModel(),
|
||||
onNotificationChange: (NotificationSettings) -> Unit,
|
||||
) {
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
if (settings.notificationSettings != null) {
|
||||
val title = settings.notificationSettings.let {
|
||||
if (it.preset != null)
|
||||
stringResource(it.preset.titleID)
|
||||
else
|
||||
it.title
|
||||
}
|
||||
val description = settings.notificationSettings.let {
|
||||
if (it.preset != null)
|
||||
stringResource(it.preset.messageID)
|
||||
else
|
||||
it.message
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationModel.initialize(
|
||||
title,
|
||||
description,
|
||||
settings.notificationSettings.showOngoing,
|
||||
settings.notificationSettings.iconID,
|
||||
)
|
||||
|
||||
if (settings.notificationSettings.preset != null) {
|
||||
notificationModel.setPreset(
|
||||
title,
|
||||
description,
|
||||
settings.notificationSettings.preset
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
|
||||
val defaultDescription =
|
||||
stringResource(R.string.ui_recorder_state_recording_description)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationModel.initialize(
|
||||
defaultTitle,
|
||||
defaultDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(modifier),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = HORIZONTAL_PADDING),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
MessageBox(
|
||||
type = MessageType.SURFACE,
|
||||
message = stringResource(R.string.ui_settings_customNotifications_edit_help)
|
||||
)
|
||||
|
||||
EditNotificationInput(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
showOngoing = notificationModel.showOngoing,
|
||||
title = notificationModel.title,
|
||||
description = notificationModel.description,
|
||||
icon = painterResource(notificationModel.icon),
|
||||
onShowOngoingChange = {
|
||||
notificationModel.showOngoing = it
|
||||
},
|
||||
onTitleChange = notificationModel::setTitle,
|
||||
onDescriptionChange = notificationModel::setDescription,
|
||||
onIconChange = {
|
||||
notificationModel.icon = it
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable {
|
||||
notificationModel.showOngoing = notificationModel.showOngoing.not()
|
||||
}
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = notificationModel.showOngoing,
|
||||
onCheckedChange = {
|
||||
notificationModel.showOngoing = it
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = MaterialTheme.colorScheme.tertiary,
|
||||
checkmarkColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.ui_settings_customNotifications_showOngoing_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
NotificationPresetsRoulette(
|
||||
onClick = notificationModel::setPreset,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onNotificationChange(
|
||||
notificationModel.asNotificationSettings()
|
||||
)
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = HORIZONTAL_PADDING)
|
||||
.height(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(ButtonDefaults.IconSpacing)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_customNotifications_save_label)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -29,7 +29,7 @@ fun AudioVisualizer(
|
||||
val width = this.size.width
|
||||
val boxWidth = width / amplitudes.size
|
||||
|
||||
amplitudes.forEachIndexed {index, amplitude ->
|
||||
amplitudes.forEachIndexed { index, amplitude ->
|
||||
val x = boxWidth * index
|
||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||
val boxHeight = height * amplitudePercentage
|
@ -0,0 +1,39 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun BatchesInaccessibleDialog(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_recorder_error_recording_title))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ui_recorder_error_batchesInaccessible_description))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onClose) {
|
||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun BigButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
description: String? = null,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
isBig: Boolean? = null,
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
BoxWithConstraints {
|
||||
val isLarge = isBig
|
||||
?: (maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(if (isLarge) 250.dp else 190.dp)
|
||||
.clip(CircleShape)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(if (isLarge) 80.dp else 60.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.myzel394.alibi.ui.utils.getCameraProvider
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
modifier: Modifier,
|
||||
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Video preview
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val previewView = PreviewView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
val previewUseCase = Preview.Builder()
|
||||
.build()
|
||||
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||
|
||||
coroutineScope.launch {
|
||||
val cameraProvider = context.getCameraProvider()
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
previewUseCase
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Log.e("CameraPreview", "Use case binding failed", ex)
|
||||
}
|
||||
}
|
||||
previewView
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
import androidx.camera.core.ExperimentalLensFacing
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Camera
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.QuestionMark
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
||||
|
||||
|
||||
@Composable
|
||||
fun CameraSelectionButton(
|
||||
cameraID: CameraInfo.Lens,
|
||||
selected: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
) {
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(
|
||||
alpha = 0.2f
|
||||
) else Color.Transparent,
|
||||
// Make animation about 0.5x faster than default
|
||||
animationSpec = spring(
|
||||
stiffness = Spring.StiffnessLow,
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
),
|
||||
label = "backgroundColor"
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onSelected)
|
||||
.background(backgroundColor)
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onSelected,
|
||||
)
|
||||
if (description == null) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
CAMERA_LENS_ICON_MAP[cameraID]!!,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val CAMERA_LENS_ICON_MAP = mapOf(
|
||||
CameraInfo.Lens.BACK to Icons.Default.Camera,
|
||||
CameraInfo.Lens.FRONT to Icons.Default.Person,
|
||||
CameraInfo.Lens.EXTERNAL to Icons.Default.Videocam,
|
||||
CameraInfo.Lens.UNKNOWN to Icons.Default.QuestionMark,
|
||||
)
|
@ -1,4 +1,4 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -11,6 +11,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -28,10 +29,10 @@ fun ConfirmDeletionDialog(
|
||||
onDismiss()
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
|
||||
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
|
||||
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
@ -40,12 +41,13 @@ fun ConfirmDeletionDialog(
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
||||
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
onClick = {
|
||||
onConfirm()
|
||||
},
|
||||
@ -61,15 +63,15 @@ fun ConfirmDeletionDialog(
|
||||
},
|
||||
dismissButton = {
|
||||
val label = stringResource(R.string.dialog_close_cancel_label)
|
||||
Button(
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Cancel,
|
@ -0,0 +1,53 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun DeleteButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDeleteDialog) {
|
||||
ConfirmDeletionDialog(
|
||||
onDismiss = {
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onConfirm = {
|
||||
showDeleteDialog = false
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.then(modifier),
|
||||
onClick = {
|
||||
showDeleteDialog = true
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
||||
|
||||
@Composable
|
||||
fun LowStorageInfo(
|
||||
modifier: Modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
appSettings: AppSettings,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val availableBytes =
|
||||
VideoBatchesFolder.importFromFolder(appSettings.saveFolder, context).getAvailableBytes()
|
||||
|
||||
if (availableBytes == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
|
||||
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
|
||||
|
||||
// Allow for a 10% margin of error
|
||||
val isLowOnStorage = availableBytes < requiredBytes * 1.1
|
||||
println("LowStorageInfo: availableBytes: $availableBytes, requiredBytes: $requiredBytes, isLowOnStorage: $isLowOnStorage")
|
||||
|
||||
if (isLowOnStorage)
|
||||
Box(modifier = modifier) {
|
||||
BoxWithConstraints {
|
||||
val isLarge = maxHeight > 600.dp;
|
||||
|
||||
MessageBox(
|
||||
type = MessageType.WARNING,
|
||||
message = if (appSettings.saveFolder == null)
|
||||
stringResource(R.string.ui_recorder_lowOnStorage_hintANDswitchSaveFolder)
|
||||
else stringResource(R.string.ui_recorder_lowOnStorage_hint),
|
||||
density = if (isLarge) VisualDensity.COMFORTABLE else VisualDensity.COMPACT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MicrophoneDisconnectedDialog(
|
||||
microphoneName: String,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_audioRecorder_error_microphoneDisconnected_title,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
||||
microphoneName,
|
||||
microphoneName,
|
||||
)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.MicOff,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val label = stringResource(R.string.dialog_close_neutral_label)
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = onClose,
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MicrophoneReconnectedDialog(
|
||||
microphoneName: String,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_audioRecorder_error_microphoneReconnected_title,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_audioRecorder_error_microphoneReconnected_message,
|
||||
microphoneName,
|
||||
)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val label = stringResource(R.string.dialog_close_neutral_label)
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = onClose,
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MicExternalOn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
|
||||
@Composable
|
||||
fun MicrophoneSelectionButton(
|
||||
microphone: MicrophoneInfo? = null,
|
||||
selected: Boolean = false,
|
||||
selectedAsFallback: Boolean = false,
|
||||
disabled: Boolean = false,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
// Copied from Android's [FilledButtonTokens]
|
||||
val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
|
||||
Button(
|
||||
onClick = onSelect,
|
||||
enabled = !disabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
|
||||
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
|
||||
) {
|
||||
MicrophoneTypeInfo(
|
||||
type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = microphone?.name
|
||||
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
|
||||
Text(
|
||||
microphone.deviceInfo.address.toString(),
|
||||
fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
|
||||
color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
if (selectedAsFallback)
|
||||
Icon(
|
||||
Icons.Default.MicExternalOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BluetoothAudio
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicExternalOn
|
||||
import androidx.compose.material.icons.filled.Smartphone
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
|
||||
@Composable
|
||||
fun MicrophoneTypeInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
type: MicrophoneInfo.MicrophoneType,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (type) {
|
||||
MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
|
||||
MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
|
||||
MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
|
||||
MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
|
||||
},
|
||||
modifier = modifier,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun PauseResumeButton(
|
||||
modifier: Modifier = Modifier,
|
||||
isPaused: Boolean,
|
||||
onChange: () -> Unit,
|
||||
) {
|
||||
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
|
||||
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
|
||||
|
||||
FloatingActionButton(
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = if (isPaused) resumeLabel else pauseLabel
|
||||
}
|
||||
.then(modifier),
|
||||
onClick = onChange,
|
||||
) {
|
||||
Icon(
|
||||
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
@ -1,16 +1,19 @@
|
||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||
import androidx.compose.foundation.gestures.transformable
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@ -19,7 +22,6 @@ import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.services.RecorderService
|
||||
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.clamp
|
||||
@ -35,10 +37,11 @@ private const val GROW_END = BOX_DIFF * 4
|
||||
|
||||
@Composable
|
||||
fun RealtimeAudioVisualizer(
|
||||
modifier: Modifier = Modifier,
|
||||
audioRecorder: AudioRecorderModel,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val amplitudes = audioRecorder.amplitudes!!
|
||||
val amplitudes = audioRecorder.amplitudes
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val primaryMuted = primary.copy(alpha = 0.3f)
|
||||
|
||||
@ -63,17 +66,28 @@ fun RealtimeAudioVisualizer(
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidth = with (LocalDensity.current) {configuration.screenWidthDp.dp.toPx()}
|
||||
|
||||
LaunchedEffect(screenWidth) {
|
||||
// Add 1 to allow the visualizer to overflow the screen
|
||||
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
|
||||
// Use greater value of width and height to make sure the amplitudes are shown
|
||||
// when the user rotates the device
|
||||
val availableSpace = with(LocalDensity.current) {
|
||||
Math.max(
|
||||
configuration.screenWidthDp.dp.toPx(),
|
||||
configuration.screenHeightDp.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(availableSpace) {
|
||||
// Add 1 to allow the visualizer to overflow the screen
|
||||
audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1)
|
||||
}
|
||||
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
val transformState = rememberTransformableState { zoomChange, _, _ ->
|
||||
scale *= zoomChange
|
||||
}
|
||||
val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale)
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
modifier = modifier.transformable(transformState),
|
||||
) {
|
||||
val height = this.size.height / 2f
|
||||
val width = this.size.width
|
||||
@ -86,9 +100,11 @@ fun RealtimeAudioVisualizer(
|
||||
val isOverThreshold = offset >= GROW_START_INDEX
|
||||
val horizontalProgress = (
|
||||
clamp(horizontalValue, GROW_START, GROW_END)
|
||||
- GROW_START) / (GROW_END - GROW_START)
|
||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||
val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
||||
- GROW_START) / (GROW_END - GROW_START)
|
||||
val amplitudePercentage =
|
||||
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
|
||||
val boxHeight =
|
||||
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
||||
|
||||
drawRoundRect(
|
||||
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,
|
@ -0,0 +1,37 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun RecorderErrorDialog(
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onClose,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_recorder_error_recording_title))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ui_recorder_error_recording_description))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onClose) {
|
||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||
|
||||
@Composable
|
||||
fun RecorderProcessingDialog(
|
||||
progress: Float?,
|
||||
) {
|
||||
KeepScreenOn()
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Memory,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_save_processing_dialog_title),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
|
||||
)
|
||||
CircularProgressIndicator()
|
||||
if (progress == null)
|
||||
LinearProgressIndicator()
|
||||
else
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SaveButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onSave: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
) {
|
||||
val label = stringResource(R.string.ui_recorder_action_save_label)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.clip(ButtonDefaults.textShape)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
||||
onClick = onSave,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.padding(ButtonDefaults.TextButtonContentPadding)
|
||||
.then(modifier)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SaveCurrentNowModal(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
|
||||
// Auto save on specific events
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_saveCurrent),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_saveCurrent_explanation),
|
||||
)
|
||||
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.ui_recorder_action_save_label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FlashlightOff
|
||||
import androidx.compose.material.icons.filled.FlashlightOn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
|
||||
@Composable
|
||||
fun TorchStatus(
|
||||
enabled: Boolean,
|
||||
onChange: () -> Unit,
|
||||
) {
|
||||
Button(
|
||||
onClick = onChange,
|
||||
colors = if (enabled) ButtonDefaults.filledTonalButtonColors() else ButtonDefaults.outlinedButtonColors(),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
if (enabled) Icons.Default.FlashlightOff else Icons.Default.FlashlightOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
if (enabled) stringResource(R.string.ui_videoRecorder_action_torch_off)
|
||||
else stringResource(R.string.ui_videoRecorder_action_torch_on),
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BigButton
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
|
||||
@Composable
|
||||
fun AudioRecordingStart(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
appSettings: AppSettings,
|
||||
useLargeButtons: Boolean? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// We can't get the current `notificationDetails` inside the
|
||||
// `onPermissionAvailable` function. We'll instead use this hack
|
||||
// with `LaunchedEffect` to get the current value.
|
||||
var startRecording by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(startRecording) {
|
||||
if (startRecording) {
|
||||
startRecording = false
|
||||
|
||||
audioRecorder.startRecording(context, appSettings)
|
||||
}
|
||||
}
|
||||
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
onPermissionAvailable = {
|
||||
startRecording = true
|
||||
}
|
||||
) { triggerExternalStorage ->
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.RECORD_AUDIO,
|
||||
icon = Icons.Default.Mic,
|
||||
onPermissionAvailable = {
|
||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
||||
triggerExternalStorage()
|
||||
} else {
|
||||
startRecording = true
|
||||
}
|
||||
}
|
||||
) { triggerRecordAudio ->
|
||||
BigButton(
|
||||
label = stringResource(R.string.ui_audioRecorder_action_start_label),
|
||||
icon = Icons.Default.Mic,
|
||||
onClick = triggerRecordAudio,
|
||||
isBig = useLargeButtons,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import CameraSelectionButton
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalLensFacing
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
||||
|
||||
@Composable
|
||||
fun CamerasSelection(
|
||||
cameras: Iterable<CameraInfo>,
|
||||
videoSettings: VideoRecorderModel,
|
||||
) {
|
||||
val CAMERA_LENS_TEXT_MAP = mapOf(
|
||||
CameraInfo.Lens.BACK to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label),
|
||||
CameraInfo.Lens.FRONT to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label),
|
||||
CameraInfo.Lens.EXTERNAL to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_external_label),
|
||||
CameraInfo.Lens.UNKNOWN to stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_unknown_label),
|
||||
)
|
||||
|
||||
Column {
|
||||
if (CameraInfo.checkHasNormalCameras(cameras)) {
|
||||
CameraSelectionButton(
|
||||
cameraID = CameraInfo.Lens.BACK,
|
||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label),
|
||||
selected = videoSettings.cameraID == CameraInfo.Lens.BACK.androidValue,
|
||||
onSelected = {
|
||||
videoSettings.cameraID = CameraInfo.Lens.BACK.androidValue
|
||||
},
|
||||
)
|
||||
CameraSelectionButton(
|
||||
cameraID = CameraInfo.Lens.FRONT,
|
||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label),
|
||||
selected = videoSettings.cameraID == CameraInfo.Lens.FRONT.androidValue,
|
||||
onSelected = {
|
||||
videoSettings.cameraID = CameraInfo.Lens.FRONT.androidValue
|
||||
},
|
||||
)
|
||||
} else {
|
||||
cameras.forEach { camera ->
|
||||
CameraSelectionButton(
|
||||
cameraID = camera.lens,
|
||||
selected = videoSettings.cameraID == camera.id,
|
||||
onSelected = {
|
||||
videoSettings.cameraID = camera.id
|
||||
},
|
||||
label = stringResource(
|
||||
R.string.ui_videoRecorder_action_start_settings_cameraLens_label,
|
||||
camera.id
|
||||
),
|
||||
description = CAMERA_LENS_TEXT_MAP[camera.lens]!!,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,223 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneSelectionButton
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneTypeInfo
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MicrophoneSelection(
|
||||
audioRecorder: AudioRecorderModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var showSelection by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
|
||||
val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
|
||||
val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
|
||||
|
||||
val isTryingToReconnect =
|
||||
audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
|
||||
|
||||
val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
|
||||
listOf(audioRecorder.selectedMicrophone!!)
|
||||
} else {
|
||||
visibleMicrophones
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
fun hideSheet() {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showSelection = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showSelection) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = ::hideSheet,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(48.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (isTryingToReconnect)
|
||||
MessageBox(
|
||||
type = MessageType.INFO,
|
||||
message = stringResource(
|
||||
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
||||
audioRecorder.selectedMicrophone?.name ?: "",
|
||||
audioRecorder.selectedMicrophone?.name ?: "",
|
||||
)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
MicrophoneSelectionButton(
|
||||
selected = audioRecorder.selectedMicrophone == null,
|
||||
selectedAsFallback = isTryingToReconnect,
|
||||
onSelect = {
|
||||
audioRecorder.changeMicrophone(null)
|
||||
hideSheet()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
items(shownMicrophones.size) {
|
||||
val microphone = shownMicrophones[it]
|
||||
|
||||
MicrophoneSelectionButton(
|
||||
microphone = microphone,
|
||||
selected = audioRecorder.selectedMicrophone == microphone,
|
||||
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
|
||||
onSelect = {
|
||||
audioRecorder.changeMicrophone(microphone)
|
||||
hideSheet()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (settings.audioRecorderSettings.showAllMicrophones) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 32.dp),
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(hiddenMicrophones.size) {
|
||||
val microphone = hiddenMicrophones[it]
|
||||
|
||||
MicrophoneSelectionButton(
|
||||
microphone = microphone,
|
||||
selected = audioRecorder.selectedMicrophone == microphone,
|
||||
onSelect = {
|
||||
audioRecorder.changeMicrophone(microphone)
|
||||
hideSheet()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We need to show a placeholder box to keep the the rest aligned correctly
|
||||
Box {}
|
||||
}
|
||||
|
||||
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
showSelection = true
|
||||
sheetState.show()
|
||||
}
|
||||
},
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
) {
|
||||
MicrophoneTypeInfo(
|
||||
type = audioRecorder.selectedMicrophone?.type
|
||||
?: MicrophoneInfo.MicrophoneType.PHONE,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
text = audioRecorder.selectedMicrophone.let {
|
||||
it?.name
|
||||
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
|
||||
}
|
||||
)
|
||||
if (isTryingToReconnect) {
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
|
||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
|
||||
@Composable
|
||||
fun MicrophoneStatus(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
) {
|
||||
val microphoneStatus = audioRecorder.microphoneStatus
|
||||
val previousStatus = rememberPrevious(microphoneStatus)
|
||||
|
||||
var showMicrophoneStatusDialog by remember {
|
||||
// null = no dialog
|
||||
// `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
|
||||
// `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
|
||||
mutableStateOf<AudioRecorderModel.MicrophoneConnectivityStatus?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(microphoneStatus) {
|
||||
if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
|
||||
showMicrophoneStatusDialog = microphoneStatus
|
||||
}
|
||||
}
|
||||
|
||||
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
|
||||
MicrophoneDisconnectedDialog(
|
||||
onClose = {
|
||||
showMicrophoneStatusDialog = null
|
||||
},
|
||||
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
|
||||
MicrophoneReconnectedDialog(
|
||||
onClose = {
|
||||
showMicrophoneStatusDialog = null
|
||||
},
|
||||
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
MicrophoneSelection(
|
||||
audioRecorder = audioRecorder,
|
||||
)
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings.Companion.EXAMPLE_MAX_DURATIONS
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
import app.myzel394.alibi.ui.utils.formatDuration
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun QuickMaxDurationSelector(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_recorder_action_changeMaxDuration_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
for (duration in EXAMPLE_MAX_DURATIONS) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onDismiss()
|
||||
}
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setMaxDuration(duration)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
) {
|
||||
Text(formatDuration(duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.DeleteButton
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.PauseResumeButton
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveButton
|
||||
import app.myzel394.alibi.ui.utils.RandomStack
|
||||
import app.myzel394.alibi.ui.utils.rememberInitialRecordingAnimation
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun RecordingControl(
|
||||
modifier: Modifier = Modifier,
|
||||
orientation: Int = LocalConfiguration.current.orientation,
|
||||
initialDelay: Long = 0L,
|
||||
isPaused: Boolean,
|
||||
recordingTime: Long,
|
||||
onDelete: () -> Unit,
|
||||
onPauseResume: () -> Unit,
|
||||
onSaveAndStop: () -> Unit,
|
||||
onSaveCurrent: () -> Unit,
|
||||
) {
|
||||
val animateIn = rememberInitialRecordingAnimation(recordingTime)
|
||||
|
||||
var deleteButtonAlphaIsIn by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val deleteButtonAlpha by animateFloatAsState(
|
||||
if (deleteButtonAlphaIsIn) 1f else 0f,
|
||||
label = "deleteButtonAlpha",
|
||||
animationSpec = tween(durationMillis = 500)
|
||||
)
|
||||
|
||||
var pauseButtonAlphaIsIn by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val pauseButtonAlpha by animateFloatAsState(
|
||||
if (pauseButtonAlphaIsIn) 1f else 0f,
|
||||
label = "pauseButtonAlpha",
|
||||
animationSpec = tween(durationMillis = 500)
|
||||
)
|
||||
|
||||
var saveButtonAlphaIsIn by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val saveButtonAlpha by animateFloatAsState(
|
||||
if (saveButtonAlphaIsIn) 1f else 0f,
|
||||
label = "saveButtonAlpha",
|
||||
animationSpec = tween(durationMillis = 500)
|
||||
)
|
||||
|
||||
LaunchedEffect(animateIn) {
|
||||
if (animateIn) {
|
||||
delay(initialDelay)
|
||||
|
||||
val stack = RandomStack.of(arrayOf(1, 2, 3).asIterable())
|
||||
|
||||
while (!stack.isEmpty()) {
|
||||
when (stack.popRandom()) {
|
||||
1 -> {
|
||||
deleteButtonAlphaIsIn = true
|
||||
}
|
||||
|
||||
2 -> {
|
||||
pauseButtonAlphaIsIn = true
|
||||
}
|
||||
|
||||
3 -> {
|
||||
saveButtonAlphaIsIn = true
|
||||
}
|
||||
}
|
||||
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(saveButtonAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SaveButton(
|
||||
onSave = onSaveAndStop,
|
||||
onLongClick = onSaveCurrent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(pauseButtonAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PauseResumeButton(
|
||||
isPaused = isPaused,
|
||||
onChange = onPauseResume,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(deleteButtonAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DeleteButton(
|
||||
onDelete = onDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.alpha(deleteButtonAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DeleteButton(onDelete = onDelete)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.alpha(pauseButtonAlpha),
|
||||
) {
|
||||
PauseResumeButton(
|
||||
isPaused = isPaused,
|
||||
onChange = onPauseResume,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.alpha(saveButtonAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SaveButton(
|
||||
onSave = onSaveAndStop,
|
||||
onLongClick = onSaveCurrent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||
import app.myzel394.alibi.ui.utils.formatDuration
|
||||
import app.myzel394.alibi.ui.utils.isSameDay
|
||||
import app.myzel394.alibi.ui.utils.rememberInitialRecordingAnimation
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun RecordingStatus(
|
||||
recordingTime: Long,
|
||||
progress: Float,
|
||||
recordingStart: LocalDateTime,
|
||||
maxDuration: Long,
|
||||
progressModifier: Modifier = Modifier.width(300.dp),
|
||||
) {
|
||||
val animateIn = rememberInitialRecordingAnimation(recordingTime)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Pulsating {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Red)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = formatDuration(recordingTime * 1000),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = animateIn,
|
||||
enter = expandHorizontally(
|
||||
tween(1000)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = progressModifier,
|
||||
drawStopIndicator = { },
|
||||
gapSize = 0.dp,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = animateIn, enter = fadeIn()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.ui_recorder_info_saveNowTime,
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.format(
|
||||
LocalDateTime.now().minusSeconds(
|
||||
min(
|
||||
maxDuration / 1000,
|
||||
recordingTime
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = animateIn, enter = fadeIn()) {
|
||||
Text(
|
||||
text = recordingStart.let {
|
||||
if (isSameDay(it, LocalDateTime.now())) {
|
||||
stringResource(
|
||||
R.string.ui_recorder_info_startTime_short,
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.format(it)
|
||||
)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.ui_recorder_info_startTime_full,
|
||||
DateTimeFormatter.ofLocalizedDateTime(
|
||||
FormatStyle.MEDIUM,
|
||||
FormatStyle.SHORT
|
||||
)
|
||||
.format(it)
|
||||
)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,242 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import android.Manifest
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.CameraPreview
|
||||
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
)
|
||||
@Composable
|
||||
fun VideoRecorderPreparationSheet(
|
||||
showPreview: Boolean,
|
||||
videoSettings: VideoRecorderModel,
|
||||
onDismiss: () -> Unit,
|
||||
onPreviewVisible: () -> Unit,
|
||||
onPreviewHidden: () -> Unit,
|
||||
onStartRecording: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
|
||||
val context = LocalContext.current
|
||||
val cameras = CameraInfo.queryAvailableCameras(context)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
videoSettings.init(context)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
dragHandle = {
|
||||
if (showPreview)
|
||||
Unit
|
||||
else
|
||||
BottomSheetDefaults.DragHandle()
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (!event.changes.elementAt(0).pressed) {
|
||||
onPreviewHidden()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (showPreview) {
|
||||
CameraPreview(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
cameraSelector = videoSettings.cameraSelector,
|
||||
)
|
||||
} else {
|
||||
BoxWithConstraints {
|
||||
val constraints = this
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET, top = 24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(30.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (constraints.maxHeight > 600.dp) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.ui_videoRecorder_action_start_settings_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.RECORD_AUDIO,
|
||||
icon = Icons.Default.Mic,
|
||||
onPermissionAvailable = {
|
||||
videoSettings.enableAudio = !videoSettings.enableAudio
|
||||
},
|
||||
) { trigger ->
|
||||
GlobalSwitch(
|
||||
label = stringResource(R.string.ui_videoRecorder_action_start_settings_enableAudio_label),
|
||||
checked = videoSettings.enableAudio,
|
||||
onCheckedChange = {
|
||||
trigger()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_selection_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
CamerasSelection(
|
||||
cameras = cameras,
|
||||
videoSettings = videoSettings,
|
||||
)
|
||||
}
|
||||
|
||||
val label =
|
||||
stringResource(R.string.ui_videoRecorder_action_start_settings_start_label)
|
||||
val hasGrantedCameraPermission =
|
||||
PermissionHelper.hasGranted(context, Manifest.permission.CAMERA)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.CAMERA,
|
||||
icon = Icons.Default.CameraAlt,
|
||||
onPermissionAvailable = {
|
||||
onStartRecording()
|
||||
}
|
||||
) { trigger ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(16.dp)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
if (hasGrantedCameraPermission) {
|
||||
onPreviewVisible()
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
trigger()
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasGrantedCameraPermission) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_videoRecorder_action_preview_label
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BigButton
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun VideoRecordingStart(
|
||||
videoRecorder: VideoRecorderModel,
|
||||
appSettings: AppSettings,
|
||||
onHideAudioRecording: () -> Unit,
|
||||
onShowAudioRecording: () -> Unit,
|
||||
showPreview: Boolean,
|
||||
useLargeButtons: Boolean? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var showSheet by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (showSheet) {
|
||||
VideoRecorderPreparationSheet(
|
||||
showPreview = showPreview,
|
||||
videoSettings = videoRecorder,
|
||||
onDismiss = {
|
||||
showSheet = false
|
||||
},
|
||||
onPreviewVisible = onHideAudioRecording,
|
||||
onPreviewHidden = onShowAudioRecording,
|
||||
onStartRecording = {
|
||||
videoRecorder.startRecording(context, appSettings)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PermissionRequester(
|
||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
onPermissionAvailable = {
|
||||
showSheet = true
|
||||
}
|
||||
) { triggerExternalStorage ->
|
||||
BigButton(
|
||||
label = stringResource(R.string.ui_videoRecorder_action_start_label),
|
||||
description = stringResource(R.string.ui_videoRecorder_action_configure_label),
|
||||
icon = Icons.Default.CameraAlt,
|
||||
onLongClick = {
|
||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
||||
triggerExternalStorage()
|
||||
return@BigButton
|
||||
}
|
||||
|
||||
showSheet = true
|
||||
},
|
||||
onClick = {
|
||||
if (appSettings.requiresExternalStoragePermission(context)) {
|
||||
triggerExternalStorage()
|
||||
return@BigButton
|
||||
}
|
||||
|
||||
if (PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) && PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
) {
|
||||
videoRecorder.startRecording(context, appSettings)
|
||||
} else {
|
||||
showSheet = true
|
||||
}
|
||||
},
|
||||
isBig = useLargeButtons,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,189 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RealtimeAudioVisualizer
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.MicrophoneStatus
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Composable
|
||||
fun AudioRecordingStatus(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current.orientation
|
||||
|
||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
now = LocalDateTime.now()
|
||||
delay(900)
|
||||
}
|
||||
}
|
||||
|
||||
KeepScreenOn()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box {}
|
||||
RealtimeAudioVisualizer(
|
||||
audioRecorder = audioRecorder,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.widthIn(max = 300.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
when (configuration) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(3f),
|
||||
) {
|
||||
RecordingStatus(
|
||||
recordingTime = audioRecorder.recordingTime,
|
||||
progress = audioRecorder.progress,
|
||||
recordingStart = audioRecorder.recordingStart,
|
||||
maxDuration = audioRecorder.settings!!.maxDuration,
|
||||
progressModifier = Modifier.fillMaxWidth(.9f),
|
||||
)
|
||||
|
||||
MicrophoneStatus(audioRecorder)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
_PrimitiveControls(audioRecorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
RecordingStatus(
|
||||
recordingTime = audioRecorder.recordingTime,
|
||||
progress = audioRecorder.progress,
|
||||
recordingStart = audioRecorder.recordingStart,
|
||||
maxDuration = audioRecorder.settings!!.maxDuration,
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MicrophoneStatus(audioRecorder)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
_PrimitiveControls(audioRecorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _PrimitiveControls(audioRecorder: AudioRecorderModel) {
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showConfirmSaveNow by remember { mutableStateOf(false) }
|
||||
|
||||
if (showConfirmSaveNow) {
|
||||
SaveCurrentNowModal(
|
||||
onDismiss = {
|
||||
showConfirmSaveNow = false
|
||||
},
|
||||
onConfirm = {
|
||||
showConfirmSaveNow = false
|
||||
|
||||
scope.launch {
|
||||
audioRecorder.recorderService!!.startNewCycle()
|
||||
|
||||
audioRecorder.onRecordingSave(false).join()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
RecordingControl(
|
||||
isPaused = audioRecorder.isPaused,
|
||||
recordingTime = audioRecorder.recordingTime,
|
||||
onDelete = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
audioRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
audioRecorder.destroyService(context)
|
||||
}
|
||||
audioRecorder.batchesFolder!!.deleteRecordings()
|
||||
}
|
||||
},
|
||||
onPauseResume = {
|
||||
if (audioRecorder.isPaused) {
|
||||
audioRecorder.resumeRecording()
|
||||
} else {
|
||||
audioRecorder.pauseRecording()
|
||||
}
|
||||
},
|
||||
onSaveAndStop = {
|
||||
scope.launch {
|
||||
audioRecorder.stopRecording(context)
|
||||
|
||||
dataStore.updateData {
|
||||
it.saveLastRecording(audioRecorder as RecorderModel)
|
||||
}
|
||||
|
||||
audioRecorder.onRecordingSave(false).join()
|
||||
|
||||
runCatching {
|
||||
audioRecorder.destroyService(context)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveCurrent = {
|
||||
showConfirmSaveNow = true
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,366 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.services.IntervalRecorderService
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.BatchesInaccessibleDialog
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RecorderErrorDialog
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RecorderProcessingDialog
|
||||
import app.myzel394.alibi.ui.effects.rememberOpenUri
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.models.BaseRecorderModel
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Timer
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
typealias RecorderModel = BaseRecorderModel<
|
||||
RecordingInformation,
|
||||
BatchesFolder,
|
||||
IntervalRecorderService<RecordingInformation, BatchesFolder>,
|
||||
>
|
||||
|
||||
@Composable
|
||||
fun RecorderEventsHandler(
|
||||
settings: AppSettings,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
audioRecorder: AudioRecorderModel,
|
||||
videoRecorder: VideoRecorderModel,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
|
||||
var isProcessing by remember { mutableStateOf(false) }
|
||||
var showRecorderError by remember { mutableStateOf(false) }
|
||||
var showBatchesInaccessibleError by remember { mutableStateOf(false) }
|
||||
|
||||
var processingProgress by remember { mutableStateOf<Float?>(null) }
|
||||
|
||||
val saveAudioFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType()) {
|
||||
if (settings.deleteRecordingsImmediately) {
|
||||
runCatching {
|
||||
audioRecorder.batchesFolder?.deleteRecordings()
|
||||
}
|
||||
}
|
||||
|
||||
if (audioRecorder.batchesFolder?.hasRecordingsAvailable() == false) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setLastRecording(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val saveVideoFile = rememberFileSaverDialog(settings.videoRecorderSettings.getMimeType()) {
|
||||
if (settings.deleteRecordingsImmediately) {
|
||||
runCatching {
|
||||
videoRecorder.batchesFolder?.deleteRecordings()
|
||||
}
|
||||
}
|
||||
|
||||
if (videoRecorder.batchesFolder?.hasRecordingsAvailable() == false) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setLastRecording(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAsLastRecording(
|
||||
recorder: RecorderModel
|
||||
) {
|
||||
if (!settings.deleteRecordingsImmediately) {
|
||||
val information = recorder.recorderService?.getRecordingInformation()
|
||||
|
||||
if (information == null) {
|
||||
Log.e("RecorderEventsHandler", "Recording information is null")
|
||||
return
|
||||
}
|
||||
|
||||
dataStore.updateData {
|
||||
it.setLastRecording(
|
||||
information
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val successMessage = stringResource(R.string.ui_recorder_action_save_success)
|
||||
val openMessage = stringResource(R.string.ui_recorder_action_save_openFolder)
|
||||
|
||||
val openFolder = rememberOpenUri()
|
||||
|
||||
fun showSnackbar() {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = successMessage,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun showSnackbar(uri: Uri) {
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = successMessage,
|
||||
actionLabel = openMessage,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
openFolder(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveRecording(
|
||||
recorder: RecorderModel,
|
||||
cleanupOldFiles: Boolean = false
|
||||
): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
// If processing takes this short, don't show the processing dialog
|
||||
val timer = Timer().schedule(250L) {
|
||||
isProcessing = true
|
||||
}
|
||||
|
||||
thread {
|
||||
runBlocking {
|
||||
try {
|
||||
if (recorder.isCurrentlyActivelyRecording) {
|
||||
recorder.recorderService?.lockFiles()
|
||||
}
|
||||
|
||||
val recording =
|
||||
// When new recording created
|
||||
recorder.recorderService?.getRecordingInformation()
|
||||
// When recording is loaded from lastRecording
|
||||
?: settings.lastRecording
|
||||
?: throw Exception("No recording information available")
|
||||
|
||||
val batchesFolder = when (recorder.javaClass) {
|
||||
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
||||
recording.folderPath,
|
||||
context
|
||||
)
|
||||
|
||||
VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder(
|
||||
recording.folderPath,
|
||||
context
|
||||
)
|
||||
|
||||
else -> throw Exception("Unknown recorder type")
|
||||
}
|
||||
|
||||
val fileName = batchesFolder.getName(
|
||||
recording.recordingStart,
|
||||
recording.fileExtension,
|
||||
)
|
||||
|
||||
batchesFolder.concatenate(
|
||||
recording,
|
||||
filenameFormat = settings.filenameFormat,
|
||||
fileName = fileName,
|
||||
onProgress = { percentage ->
|
||||
processingProgress = percentage
|
||||
}
|
||||
)
|
||||
|
||||
// Save file
|
||||
when (batchesFolder.type) {
|
||||
BatchesFolder.BatchType.INTERNAL -> {
|
||||
when (batchesFolder) {
|
||||
is AudioBatchesFolder -> {
|
||||
saveAudioFile(
|
||||
batchesFolder.asInternalGetOutputFile(fileName), fileName
|
||||
)
|
||||
}
|
||||
|
||||
is VideoBatchesFolder -> {
|
||||
saveVideoFile(
|
||||
batchesFolder.asInternalGetOutputFile(fileName), fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.CUSTOM -> {
|
||||
showSnackbar(batchesFolder.customFolder!!.uri)
|
||||
|
||||
if (settings.deleteRecordingsImmediately) {
|
||||
batchesFolder.deleteRecordings()
|
||||
}
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.MEDIA -> {
|
||||
showSnackbar()
|
||||
|
||||
if (settings.deleteRecordingsImmediately) {
|
||||
batchesFolder.deleteRecordings()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.getStackTraceString(error)
|
||||
} finally {
|
||||
if (recorder.isCurrentlyActivelyRecording) {
|
||||
recorder.recorderService?.unlockFiles(cleanupOldFiles)
|
||||
}
|
||||
timer.cancel()
|
||||
isProcessing = false
|
||||
processingProgress = null
|
||||
completer.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completer
|
||||
}
|
||||
|
||||
// Register audio recorder events
|
||||
// Absolutely no idea, but somehow on some devices the `DisposableEffect`
|
||||
// is registered twice, and THEN disposed once (AFTER being called twice),
|
||||
// which then causes the `onRecordingSave` to be in a weird state.
|
||||
// This variable is a workaround to prevent this from happening.
|
||||
var previousAudioSettings: AppSettings? = null
|
||||
DisposableEffect(settings) {
|
||||
if (previousAudioSettings == settings) {
|
||||
onDispose { }
|
||||
} else {
|
||||
previousAudioSettings = settings
|
||||
audioRecorder.onRecordingSave = { cleanupOldFiles ->
|
||||
saveRecording(audioRecorder as RecorderModel, cleanupOldFiles)
|
||||
}
|
||||
audioRecorder.onRecordingStart = {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
}
|
||||
audioRecorder.onError = {
|
||||
scope.launch {
|
||||
saveAsLastRecording(audioRecorder as RecorderModel)
|
||||
|
||||
runCatching {
|
||||
audioRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
audioRecorder.destroyService(context)
|
||||
}
|
||||
|
||||
showRecorderError = true
|
||||
}
|
||||
}
|
||||
audioRecorder.onBatchesFolderNotAccessible = {
|
||||
scope.launch {
|
||||
showBatchesInaccessibleError = true
|
||||
|
||||
runCatching {
|
||||
audioRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
audioRecorder.destroyService(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
audioRecorder.onRecordingSave = {
|
||||
throw NotImplementedError("onRecordingSave should not be called now")
|
||||
}
|
||||
audioRecorder.onError = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register video recorder events
|
||||
var previousVideoSettings: AppSettings? = null
|
||||
DisposableEffect(settings) {
|
||||
if (previousVideoSettings == settings) {
|
||||
onDispose { }
|
||||
} else {
|
||||
previousVideoSettings = settings
|
||||
Log.i("Alibi", "===== Registering videoRecorder events $videoRecorder")
|
||||
videoRecorder.onRecordingSave = { cleanupOldFiles ->
|
||||
saveRecording(videoRecorder as RecorderModel, cleanupOldFiles)
|
||||
}
|
||||
videoRecorder.onRecordingStart = {
|
||||
snackbarHostState.currentSnackbarData?.dismiss()
|
||||
}
|
||||
videoRecorder.onError = {
|
||||
scope.launch {
|
||||
saveAsLastRecording(videoRecorder as RecorderModel)
|
||||
|
||||
runCatching {
|
||||
videoRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
videoRecorder.destroyService(context)
|
||||
}
|
||||
|
||||
showRecorderError = true
|
||||
}
|
||||
}
|
||||
videoRecorder.onBatchesFolderNotAccessible = {
|
||||
scope.launch {
|
||||
showBatchesInaccessibleError = true
|
||||
|
||||
runCatching {
|
||||
videoRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
videoRecorder.destroyService(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
Log.i("Alibi", "===== Disposing videoRecorder events")
|
||||
videoRecorder.onRecordingSave = {
|
||||
throw NotImplementedError("onRecordingSave should not be called now")
|
||||
}
|
||||
videoRecorder.onError = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isProcessing)
|
||||
RecorderProcessingDialog(
|
||||
progress = processingProgress,
|
||||
)
|
||||
|
||||
if (showBatchesInaccessibleError)
|
||||
BatchesInaccessibleDialog(
|
||||
onClose = {
|
||||
showBatchesInaccessibleError = false
|
||||
},
|
||||
)
|
||||
else if (showRecorderError)
|
||||
RecorderErrorDialog(
|
||||
onClose = {
|
||||
showRecorderError = false
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,234 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_MAX_WIDTH
|
||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.LowStorageInfo
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.AudioRecordingStart
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.QuickMaxDurationSelector
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.VideoRecordingStart
|
||||
import app.myzel394.alibi.ui.effects.rememberForceUpdateOnLifeCycleChange
|
||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@Composable
|
||||
fun StartRecording(
|
||||
audioRecorder: AudioRecorderModel,
|
||||
videoRecorder: VideoRecorderModel,
|
||||
// Loading this from parent, because if we load it ourselves
|
||||
// and permissions have already been granted, initial
|
||||
// settings will be used, instead of the actual settings.
|
||||
appSettings: AppSettings,
|
||||
onSaveLastRecording: () -> Unit,
|
||||
onHideTopBar: () -> Unit,
|
||||
onShowTopBar: () -> Unit,
|
||||
showAudioRecorder: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
val label = stringResource(
|
||||
R.string.ui_recorder_action_start_description_2,
|
||||
appSettings.maxDuration / 1000 / 60
|
||||
)
|
||||
val annotatedDescription = buildAnnotatedString {
|
||||
append(stringResource(R.string.ui_recorder_action_start_description_1))
|
||||
|
||||
withStyle(SpanStyle(background = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
pushStringAnnotation(
|
||||
tag = "minutes",
|
||||
annotation = label,
|
||||
)
|
||||
append(label)
|
||||
}
|
||||
|
||||
append(stringResource(R.string.ui_recorder_action_start_description_3))
|
||||
}
|
||||
|
||||
var showQuickMaxDurationSelector by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (showQuickMaxDurationSelector) {
|
||||
QuickMaxDurationSelector(
|
||||
onDismiss = {
|
||||
showQuickMaxDurationSelector = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
BoxWithConstraints {
|
||||
val isLargeDisplay =
|
||||
maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 0.dp else 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when (orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showAudioRecorder)
|
||||
AudioRecordingStart(
|
||||
audioRecorder = audioRecorder,
|
||||
appSettings = appSettings,
|
||||
)
|
||||
VideoRecordingStart(
|
||||
videoRecorder = videoRecorder,
|
||||
appSettings = appSettings,
|
||||
onHideAudioRecording = onHideTopBar,
|
||||
onShowAudioRecording = onShowTopBar,
|
||||
showPreview = !showAudioRecorder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (showAudioRecorder)
|
||||
AudioRecordingStart(
|
||||
audioRecorder = audioRecorder,
|
||||
appSettings = appSettings,
|
||||
useLargeButtons = isLargeDisplay,
|
||||
)
|
||||
VideoRecordingStart(
|
||||
videoRecorder = videoRecorder,
|
||||
appSettings = appSettings,
|
||||
onHideAudioRecording = onHideTopBar,
|
||||
onShowAudioRecording = onShowTopBar,
|
||||
showPreview = !showAudioRecorder,
|
||||
useLargeButtons = isLargeDisplay,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(forceUpdate),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
|
||||
val label = stringResource(
|
||||
R.string.ui_recorder_action_saveOldRecording_label,
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
|
||||
.format(appSettings.lastRecording.recordingStart),
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.requiredWidthIn(max = BIG_PRIMARY_BUTTON_MAX_WIDTH)
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
},
|
||||
onClick = onSaveLastRecording,
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(label)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.launcher_monochrome_noopacity),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
|
||||
ClickableText(
|
||||
text = annotatedDescription,
|
||||
onClick = { textIndex ->
|
||||
if (annotatedDescription.getStringAnnotations(textIndex, textIndex)
|
||||
.firstOrNull()?.tag == "minutes"
|
||||
) {
|
||||
showQuickMaxDurationSelector = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LowStorageInfo(
|
||||
modifier = if (isLargeDisplay) Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 400.dp) else Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp),
|
||||
appSettings = appSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,295 @@
|
||||
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.TorchStatus
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
|
||||
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||
import app.myzel394.alibi.ui.utils.CameraInfo
|
||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun VideoRecordingStatus(
|
||||
videoRecorder: VideoRecorderModel,
|
||||
) {
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
KeepScreenOn()
|
||||
|
||||
when (orientation) {
|
||||
Configuration.ORIENTATION_LANDSCAPE -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(32.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(0.9f)
|
||||
.align(Alignment.CenterVertically),
|
||||
) {
|
||||
_VideoGeneralInfo(videoRecorder)
|
||||
_VideoRecordingStatus(videoRecorder)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(0.9f)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
_VideoControls(videoRecorder)
|
||||
HorizontalDivider()
|
||||
_PrimitiveControls(videoRecorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Box {}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(16.dp),
|
||||
) {
|
||||
_VideoGeneralInfo(videoRecorder)
|
||||
_VideoRecordingStatus(videoRecorder)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
_VideoControls(videoRecorder)
|
||||
HorizontalDivider()
|
||||
_PrimitiveControls(videoRecorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _VideoGeneralInfo(videoRecorder: VideoRecorderModel) {
|
||||
val context = LocalContext.current
|
||||
val availableCameras = CameraInfo.queryAvailableCameras(context)
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(if (orientation == Configuration.ORIENTATION_LANDSCAPE) 12.dp else 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if (orientation == Configuration.ORIENTATION_LANDSCAPE) 48.dp else 64.dp)
|
||||
)
|
||||
|
||||
if (videoRecorder.isStartingRecording) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(128.dp)
|
||||
.height(
|
||||
with(LocalDensity.current) {
|
||||
MaterialTheme.typography.labelMedium.fontSize.toDp()
|
||||
}
|
||||
)
|
||||
.shimmer()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
MaterialTheme.shapes.small
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.form_value_selected,
|
||||
if (CameraInfo.checkHasNormalCameras(availableCameras)) {
|
||||
videoRecorder.cameraID.let {
|
||||
if (it == CameraInfo.Lens.BACK.androidValue)
|
||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_back_label)
|
||||
else
|
||||
stringResource(R.string.ui_videoRecorder_action_start_settings_cameraLens_front_label)
|
||||
}
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.ui_videoRecorder_action_start_settings_cameraLens_label,
|
||||
videoRecorder.cameraID
|
||||
)
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _VideoRecordingStatus(videoRecorder: VideoRecorderModel) {
|
||||
RecordingStatus(
|
||||
recordingTime = videoRecorder.recordingTime,
|
||||
progress = videoRecorder.progress,
|
||||
recordingStart = videoRecorder.recordingStart,
|
||||
maxDuration = videoRecorder.settings!!.maxDuration,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _PrimitiveControls(videoRecorder: VideoRecorderModel) {
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showConfirmSaveNow by remember { mutableStateOf(false) }
|
||||
|
||||
if (showConfirmSaveNow) {
|
||||
SaveCurrentNowModal(
|
||||
onDismiss = {
|
||||
showConfirmSaveNow = false
|
||||
},
|
||||
onConfirm = {
|
||||
showConfirmSaveNow = false
|
||||
|
||||
scope.launch {
|
||||
videoRecorder.recorderService!!.startNewCycle()
|
||||
|
||||
videoRecorder.onRecordingSave(false).join()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
RecordingControl(
|
||||
orientation = Configuration.ORIENTATION_PORTRAIT,
|
||||
// There may be some edge cases where the app may crash if the
|
||||
// user stops or pauses the recording too soon, so we simply add a
|
||||
// small delay to prevent that
|
||||
initialDelay = 1000L,
|
||||
isPaused = videoRecorder.isPaused,
|
||||
recordingTime = videoRecorder.recordingTime,
|
||||
onDelete = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
videoRecorder.stopRecording(context)
|
||||
}
|
||||
runCatching {
|
||||
videoRecorder.destroyService(context)
|
||||
}
|
||||
videoRecorder.batchesFolder!!.deleteRecordings()
|
||||
}
|
||||
},
|
||||
onPauseResume = {
|
||||
if (videoRecorder.isPaused) {
|
||||
videoRecorder.resumeRecording()
|
||||
} else {
|
||||
videoRecorder.pauseRecording()
|
||||
}
|
||||
},
|
||||
onSaveAndStop = {
|
||||
println("User initiated video recording save and stop")
|
||||
scope.launch {
|
||||
Log.i("Alibi", "====== Asking to stop recording...")
|
||||
videoRecorder.stopRecording(context)
|
||||
Log.i("Alibi", "====== Asking to stop recording... done")
|
||||
|
||||
Log.i("Alibi", "====== Updating data store...")
|
||||
dataStore.updateData {
|
||||
it.saveLastRecording(videoRecorder as RecorderModel)
|
||||
}
|
||||
Log.i("Alibi", "====== Updating data store... done")
|
||||
|
||||
Log.i("Alibi", "===== Asking to save recording...")
|
||||
videoRecorder.onRecordingSave(false).join()
|
||||
Log.i("Alibi", "===== Asking to save recording... done")
|
||||
|
||||
Log.i("Alibi", "===== Destroying service...")
|
||||
runCatching {
|
||||
videoRecorder.destroyService(context)
|
||||
}
|
||||
Log.i("Alibi", "===== Destroying service... done")
|
||||
}
|
||||
},
|
||||
onSaveCurrent = {
|
||||
showConfirmSaveNow = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _VideoControls(videoRecorder: VideoRecorderModel) {
|
||||
if (!videoRecorder.isStartingRecording) {
|
||||
val cameraControl = videoRecorder.recorderService!!.cameraControl!!
|
||||
if (cameraControl.hasTorchAvailable()) {
|
||||
var torchEnabled by rememberSaveable { mutableStateOf(cameraControl.torchEnabled) }
|
||||
|
||||
TorchStatus(
|
||||
enabled = torchEnabled,
|
||||
onChange = {
|
||||
if (torchEnabled) {
|
||||
torchEnabled = false
|
||||
cameraControl.disableTorch()
|
||||
} else {
|
||||
torchEnabled = true
|
||||
cameraControl.enableTorch()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.enums.Screen
|
||||
|
||||
@Composable
|
||||
fun AboutTile(
|
||||
onNavigateToAboutScreen: () -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.ui_about_title)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 48.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
onNavigateToAboutScreen()
|
||||
}
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitrateTile() {
|
||||
fun AudioRecorderBitrateTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
fun updateValue(bitRate: Int) {
|
||||
scope.launch {
|
||||
@ -81,11 +77,11 @@ fun BitrateTile() {
|
||||
val bitRate = text?.toIntOrNull()
|
||||
|
||||
if (bitRate == null) {
|
||||
ValidationResult.Invalid(notNumberLabel)
|
||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||
}
|
||||
|
||||
if (bitRate !in 1..320) {
|
||||
ValidationResult.Invalid(notInRangeLabel)
|
||||
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
|
||||
}
|
||||
|
||||
ValidationResult.Valid
|
||||
@ -94,7 +90,9 @@ fun BitrateTile() {
|
||||
)
|
||||
),
|
||||
) { result ->
|
||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException("Bitrate is null")
|
||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException(
|
||||
"Bitrate is null"
|
||||
)
|
||||
|
||||
updateValue(bitRate * 1000)
|
||||
}
|
||||
@ -128,7 +126,7 @@ fun BitrateTile() {
|
||||
ExampleListRoulette(
|
||||
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
||||
onItemSelected = ::updateValue,
|
||||
) {bitRate ->
|
||||
) { bitRate ->
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.format_kbps,
|
@ -1,8 +1,7 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AudioFile
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@ -13,7 +12,6 @@ import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -34,18 +32,16 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EncoderTile(
|
||||
snackbarHostState: SnackbarHostState
|
||||
fun AudioRecorderEncoderTile(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
val updatedOutputFormatLabel = stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
|
||||
val updatedOutputFormatLabel =
|
||||
stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
|
||||
|
||||
fun updateValue(encoder: Int?) {
|
||||
scope.launch {
|
||||
@ -92,7 +88,7 @@ fun EncoderTile(
|
||||
selected = settings.audioRecorderSettings.encoder == index,
|
||||
)
|
||||
}.toList()
|
||||
) {index, option ->
|
||||
) { index, _ ->
|
||||
updateValue(index)
|
||||
},
|
||||
)
|
@ -1,6 +1,5 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AudioFile
|
||||
import androidx.compose.material3.Button
|
||||
@ -8,13 +7,8 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarVisuals
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -35,14 +29,12 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutputFormatTile() {
|
||||
fun AudioRecorderOutputFormatTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
|
||||
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
|
||||
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
|
||||
@ -74,7 +66,7 @@ fun OutputFormatTile() {
|
||||
selected = settings.audioRecorderSettings.outputFormat == option,
|
||||
)
|
||||
}.toList()
|
||||
) {index, option ->
|
||||
) { index, _ ->
|
||||
updateValue(availableOptions[index])
|
||||
},
|
||||
)
|
@ -1,9 +1,8 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SamplingRateTile() {
|
||||
fun AudioRecorderSamplingRateTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
fun updateValue(samplingRate: Int?) {
|
||||
scope.launch {
|
||||
@ -62,7 +58,8 @@ fun SamplingRateTile() {
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
||||
icon = IconSource(
|
||||
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
|
||||
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
|
||||
.asPainterResource(),
|
||||
contentDescription = null,
|
||||
)
|
||||
),
|
||||
@ -81,11 +78,11 @@ fun SamplingRateTile() {
|
||||
val samplingRate = text?.toIntOrNull()
|
||||
|
||||
if (samplingRate == null) {
|
||||
ValidationResult.Invalid(notNumberLabel)
|
||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||
}
|
||||
|
||||
if (samplingRate!! <= 1000) {
|
||||
ValidationResult.Invalid(mustBeGreaterThanLabel)
|
||||
if (samplingRate <= 1000) {
|
||||
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
|
||||
}
|
||||
|
||||
ValidationResult.Valid
|
||||
@ -94,7 +91,8 @@ fun SamplingRateTile() {
|
||||
)
|
||||
),
|
||||
) { result ->
|
||||
val samplingRate = result.getString("samplingRate")?.toIntOrNull() ?: throw IllegalStateException("SamplingRate is null")
|
||||
val samplingRate = result.getString("samplingRate")?.toIntOrNull()
|
||||
?: throw IllegalStateException("SamplingRate is null")
|
||||
|
||||
updateValue(samplingRate)
|
||||
}
|
||||
@ -117,7 +115,8 @@ fun SamplingRateTile() {
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
(settings.audioRecorderSettings.samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||
(settings.audioRecorderSettings.samplingRate
|
||||
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||
)
|
||||
}
|
||||
},
|
||||
@ -125,9 +124,10 @@ fun SamplingRateTile() {
|
||||
ExampleListRoulette(
|
||||
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
||||
onItemSelected = ::updateValue,
|
||||
) {samplingRate ->
|
||||
) { samplingRate ->
|
||||
Text(
|
||||
(samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||
(samplingRate
|
||||
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||
)
|
||||
}
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.MicExternalOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -13,25 +12,21 @@ import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@Composable
|
||||
fun ForceExactMaxDurationTile() {
|
||||
fun AudioRecorderShowAllMicrophonesTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
fun updateValue(forceExactMaxDuration: Boolean) {
|
||||
fun updateValue(showAllMicrophones: Boolean) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setAudioRecorderSettings(
|
||||
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
|
||||
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -39,17 +34,17 @@ fun ForceExactMaxDurationTile() {
|
||||
|
||||
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
|
||||
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
|
||||
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
|
||||
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.GraphicEq,
|
||||
Icons.Default.MicExternalOn,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = settings.audioRecorderSettings.forceExactMaxDuration,
|
||||
checked = settings.audioRecorderSettings.showAllMicrophones,
|
||||
onCheckedChange = ::updateValue,
|
||||
)
|
||||
},
|
@ -0,0 +1,57 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.navigation.NavController
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.enums.Screen
|
||||
|
||||
@Composable
|
||||
fun CustomNotificationTile(
|
||||
onNavigateToCustomRecordingNotifications: () -> Unit,
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
val label = if (settings.notificationSettings == null)
|
||||
stringResource(R.string.ui_settings_option_customNotification_description_setup)
|
||||
else stringResource(
|
||||
R.string.ui_settings_option_customNotification_description_edit
|
||||
)
|
||||
|
||||
SettingsTile(
|
||||
firstModifier = Modifier
|
||||
.clickable {
|
||||
|
||||
onNavigateToCustomRecordingNotifications()
|
||||
}
|
||||
.semantics { contentDescription = label },
|
||||
title = stringResource(R.string.ui_settings_option_customNotification_title),
|
||||
description = label,
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.Notifications,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun DeleteRecordingsImmediatelyTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_title),
|
||||
description = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_description),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.DeleteSweep,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = settings.deleteRecordingsImmediately,
|
||||
onCheckedChange = {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import android.os.Message
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.magnifier
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppLockSettings
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.AppLockHelper
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun EnableAppLockTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
|
||||
val appLockSupport = AppLockHelper.getSupportType(context)
|
||||
|
||||
if (appLockSupport === AppLockHelper.SupportType.UNAVAILABLE) {
|
||||
return
|
||||
}
|
||||
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_enableAppLock_title),
|
||||
description = stringResource(R.string.ui_settings_option_enableAppLock_description),
|
||||
tertiaryLine = {
|
||||
if (appLockSupport === AppLockHelper.SupportType.NONE_ENROLLED) {
|
||||
Box(
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
MessageBox(
|
||||
type = MessageType.WARNING,
|
||||
message = stringResource(R.string.ui_settings_option_enableAppLock_enrollmentRequired),
|
||||
density = VisualDensity.COMPACT,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.Fingerprint,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
val title = stringResource(R.string.identityVerificationRequired_title)
|
||||
val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
|
||||
|
||||
Switch(
|
||||
checked = settings.isAppLockEnabled(),
|
||||
enabled = appLockSupport === AppLockHelper.SupportType.AVAILABLE,
|
||||
onCheckedChange = {
|
||||
scope.launch {
|
||||
val authenticationSuccessful = AppLockHelper.authenticate(
|
||||
context,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
).await()
|
||||
|
||||
if (!authenticationSuccessful) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
dataStore.updateData {
|
||||
it.setAppLockSettings(
|
||||
if (it.appLockSettings == null)
|
||||
AppLockSettings.getDefaultInstance()
|
||||
else
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,235 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.TextSnippet
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Timelapse
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val FORMAT_RESOURCE_MAP: Map<AppSettings.FilenameFormat, Int> = mapOf(
|
||||
AppSettings.FilenameFormat.DATETIME_RELATIVE_START to R.string.ui_settings_option_filenameFormat_action_relativeStart_label,
|
||||
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START to R.string.ui_settings_option_filenameFormat_action_absoluteStart_label,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilenameFormatTile(
|
||||
settings: AppSettings,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
|
||||
val successMessage = stringResource(R.string.ui_settings_option_filenameFormat_success)
|
||||
fun updateValue(format: AppSettings.FilenameFormat) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setFilenameFormat(format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectionVisible by remember { mutableStateOf(false) }
|
||||
val selectionSheetState = rememberModalBottomSheetState(true)
|
||||
|
||||
fun hideSheet() {
|
||||
scope.launch {
|
||||
selectionSheetState.hide()
|
||||
selectionVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionVisible) {
|
||||
SelectionSheet(
|
||||
sheetState = selectionSheetState,
|
||||
updateValue = { format ->
|
||||
hideSheet()
|
||||
|
||||
if (format != null) {
|
||||
updateValue(format)
|
||||
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = successMessage,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = ::hideSheet,
|
||||
)
|
||||
}
|
||||
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_filenameFormat_title),
|
||||
description = stringResource(R.string.ui_settings_option_filenameFormat_explanation),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.TextSnippet,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
selectionVisible = true
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(FORMAT_RESOURCE_MAP[settings.filenameFormat]!!),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectionSheet(
|
||||
sheetState: SheetState,
|
||||
updateValue: (AppSettings.FilenameFormat?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_filenameFormat_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_label),
|
||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_explanation),
|
||||
icon = Icons.Default.AccessTime,
|
||||
onClick = {
|
||||
updateValue(AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_label),
|
||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_explanation),
|
||||
icon = Icons.Default.Timelapse,
|
||||
onClick = {
|
||||
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_filenameFormat_action_now_label),
|
||||
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_now_explanation),
|
||||
icon = Icons.Default.Circle,
|
||||
onClick = {
|
||||
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionButton(
|
||||
label: String,
|
||||
explanation: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
.fillMaxWidth(0.1f),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(0.9f),
|
||||
) {
|
||||
Text(label)
|
||||
Text(
|
||||
explanation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||
import app.myzel394.alibi.ui.utils.rememberFileSelectorDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun ImportExport(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
var settingsToBeImported by remember { mutableStateOf<AppSettings?>(null) }
|
||||
|
||||
val saveFile = rememberFileSaverDialog("application/json")
|
||||
val openFile = rememberFileSelectorDialog { uri ->
|
||||
val file = File.createTempFile("alibi_settings", ".json")
|
||||
|
||||
context.contentResolver.openInputStream(uri)!!.use {
|
||||
it.copyTo(file.outputStream())
|
||||
}
|
||||
val rawContent = file.readText()
|
||||
|
||||
settingsToBeImported = AppSettings.fromExportedString(rawContent)
|
||||
}
|
||||
|
||||
if (settingsToBeImported != null) {
|
||||
val successMessage = stringResource(R.string.ui_settings_option_import_success)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
settingsToBeImported = null
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_settings_option_import_label))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ui_settings_option_import_dialog_text))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
settingsToBeImported!!
|
||||
}
|
||||
settingsToBeImported = null
|
||||
|
||||
snackbarHostState.showSnackbar(
|
||||
message = successMessage,
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.ui_settings_option_import_dialog_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
settingsToBeImported = null
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_close_cancel_label))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
openFile("application/json")
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Download,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.ui_settings_option_import_label))
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
val rawContent = settings.exportToString()
|
||||
|
||||
val tempFile = File.createTempFile("alibi_settings", ".json")
|
||||
tempFile.writeText(rawContent)
|
||||
|
||||
saveFile(tempFile, "alibi_settings.json")
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Upload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.ui_settings_option_export_label))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -34,21 +32,23 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IntervalDurationTile() {
|
||||
fun IntervalDurationTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
fun updateValue(intervalDuration: Long) {
|
||||
scope.launch {
|
||||
if (intervalDuration > settings.maxDuration) {
|
||||
dataStore.updateData {
|
||||
it.setMaxDuration(intervalDuration)
|
||||
}
|
||||
}
|
||||
|
||||
dataStore.updateData {
|
||||
it.setAudioRecorderSettings(
|
||||
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
|
||||
)
|
||||
it.setIntervalDuration(intervalDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -67,7 +67,7 @@ fun IntervalDurationTile() {
|
||||
},
|
||||
config = DurationConfig(
|
||||
timeFormat = DurationFormat.MM_SS,
|
||||
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
|
||||
currentTime = settings.intervalDuration / 1000,
|
||||
minTime = 10,
|
||||
maxTime = 60 * 60,
|
||||
)
|
||||
@ -90,7 +90,7 @@ fun IntervalDurationTile() {
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
|
||||
text = formatDuration(settings.intervalDuration),
|
||||
)
|
||||
}
|
||||
},
|
||||
@ -98,7 +98,7 @@ fun IntervalDurationTile() {
|
||||
ExampleListRoulette(
|
||||
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
||||
onItemSelected = ::updateValue,
|
||||
) {duration ->
|
||||
) { duration ->
|
||||
Text(
|
||||
text = formatDuration(duration),
|
||||
)
|
@ -1,7 +1,6 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -33,21 +31,23 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MaxDurationTile() {
|
||||
fun MaxDurationTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
fun updateValue(maxDuration: Long) {
|
||||
scope.launch {
|
||||
if (maxDuration < settings.intervalDuration) {
|
||||
dataStore.updateData {
|
||||
it.setIntervalDuration(maxDuration)
|
||||
}
|
||||
}
|
||||
|
||||
dataStore.updateData {
|
||||
it.setAudioRecorderSettings(
|
||||
it.audioRecorderSettings.setMaxDuration(maxDuration)
|
||||
)
|
||||
it.setMaxDuration(maxDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,10 +65,10 @@ fun MaxDurationTile() {
|
||||
updateValue(newTimeInSeconds * 1000L)
|
||||
},
|
||||
config = DurationConfig(
|
||||
timeFormat = DurationFormat.MM_SS,
|
||||
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
|
||||
timeFormat = DurationFormat.HH_MM,
|
||||
currentTime = settings.maxDuration / 1000,
|
||||
minTime = 60,
|
||||
maxTime = 24 * 60 * 60,
|
||||
maxTime = 23 * 60 * 60 + 59 * 60,
|
||||
)
|
||||
)
|
||||
SettingsTile(
|
||||
@ -88,14 +88,14 @@ fun MaxDurationTile() {
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
|
||||
Text(formatDuration(settings.maxDuration))
|
||||
}
|
||||
},
|
||||
extra = {
|
||||
ExampleListRoulette(
|
||||
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
|
||||
onItemSelected = ::updateValue,
|
||||
) {maxDuration ->
|
||||
) { maxDuration ->
|
||||
Text(formatDuration(maxDuration))
|
||||
}
|
||||
}
|
@ -0,0 +1,694 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.PermMedia
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import app.myzel394.alibi.ui.VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.FolderBreadcrumbs
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.effects.rememberOpenUri
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SaveFolderTile(
|
||||
settings: AppSettings,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dataStore = context.dataStore
|
||||
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
val successMessage = stringResource(R.string.ui_settings_option_saveFolder_success)
|
||||
fun updateValue(path: String?) {
|
||||
if (path != null && path != RECORDER_MEDIA_SELECTED_VALUE) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
path.toUri(),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
|
||||
if (!BatchesFolder.canAccessFolder(context, path.toUri())) {
|
||||
showError = true
|
||||
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
path.toUri(),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
// Clean up
|
||||
val grantedURIs = context.contentResolver.persistedUriPermissions;
|
||||
|
||||
grantedURIs.forEach { permission ->
|
||||
if (permission.uri == path?.toUri()) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
permission.uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setSaveFolder(path)
|
||||
}
|
||||
snackbarHostState.showSnackbar(
|
||||
message = successMessage,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var selectionVisible by remember { mutableStateOf(false) }
|
||||
val selectionSheetState = rememberModalBottomSheetState(true)
|
||||
|
||||
fun hideSheet() {
|
||||
scope.launch {
|
||||
selectionSheetState.hide()
|
||||
selectionVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showError) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showError = false
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_error_occurred_title))
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
showError = false
|
||||
}) {
|
||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_batchesFolderInaccessible_error),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (selectionVisible) {
|
||||
SelectionSheet(
|
||||
sheetState = selectionSheetState,
|
||||
updateValue = { path ->
|
||||
updateValue(path)
|
||||
hideSheet()
|
||||
},
|
||||
onDismiss = ::hideSheet,
|
||||
)
|
||||
}
|
||||
|
||||
var showDCIMFolderHelpSheet by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDCIMFolderHelpSheet) {
|
||||
DCIMFolderExplanationDialog(
|
||||
onDismiss = {
|
||||
showDCIMFolderHelpSheet = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var showExplanationDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showExplanationDialog) {
|
||||
InternalFolderExplanationDialog(
|
||||
onDismiss = {
|
||||
showExplanationDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_saveFolder_title),
|
||||
description = stringResource(R.string.ui_settings_option_saveFolder_explanation),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
selectionVisible = true
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ui_settings_option_saveFolder_action_select_label),
|
||||
)
|
||||
}
|
||||
},
|
||||
extra = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.form_value_selected,
|
||||
when (settings.saveFolder) {
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> stringResource(R.string.ui_settings_option_saveFolder_dcimValue)
|
||||
null -> stringResource(R.string.ui_settings_option_saveFolder_defaultValue)
|
||||
else -> splitPath(settings.saveFolder).joinToString(" > ")
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
|
||||
val openFolder = rememberOpenUri()
|
||||
|
||||
when (settings.saveFolder) {
|
||||
null -> {
|
||||
Button(
|
||||
onClick = {
|
||||
showExplanationDialog = true
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> {
|
||||
Button(
|
||||
onClick = {
|
||||
showDCIMFolderHelpSheet = true
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Custom folder
|
||||
else ->
|
||||
// Doesn't seem to reliably work on all devices, 30 & 33
|
||||
// has been tested; so we just show the button for versions
|
||||
// above 30
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
Button(
|
||||
onClick = {
|
||||
openFolder(
|
||||
DocumentFile.fromTreeUri(
|
||||
context,
|
||||
settings.saveFolder.toUri(),
|
||||
)!!.uri
|
||||
)
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_openFolder_label),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DCIMFolderExplanationDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.PermMedia,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label))
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_generalExplanation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
// I tried adding support for opening the folder directly by tapping on the
|
||||
// breadcrumbs, but couldn't get it to work.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
)
|
||||
FolderBreadcrumbs(
|
||||
folders = listOf(
|
||||
if (SUPPORTS_SCOPED_STORAGE)
|
||||
AudioBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH
|
||||
else
|
||||
AudioBatchesFolder.BASE_LEGACY_STORAGE_FOLDER,
|
||||
MEDIA_SUBFOLDER_NAME
|
||||
)
|
||||
)
|
||||
Box {}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CameraAlt,
|
||||
contentDescription = null,
|
||||
)
|
||||
FolderBreadcrumbs(
|
||||
folders = listOf(
|
||||
if (SUPPORTS_SCOPED_STORAGE)
|
||||
VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH
|
||||
else
|
||||
VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER,
|
||||
MEDIA_SUBFOLDER_NAME
|
||||
)
|
||||
)
|
||||
Box {}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.ui_settings_option_saveFolder_explainMediaFolder_subfoldersExplanation,
|
||||
AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||
VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InternalFolderExplanationDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.ui_settings_option_saveFolder_explainMediaFolder_label))
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_explainInternalFolder_explanation),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectionSheet(
|
||||
sheetState: SheetState,
|
||||
updateValue: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val selectFolder = rememberFolderSelectorDialog { folder ->
|
||||
if (folder == null) {
|
||||
return@rememberFolderSelectorDialog
|
||||
}
|
||||
|
||||
updateValue(folder.toString())
|
||||
}
|
||||
|
||||
var showExternalPermissionRequired by remember { mutableStateOf(false) }
|
||||
|
||||
if (showExternalPermissionRequired) {
|
||||
ExternalPermissionRequiredDialog(
|
||||
onDismiss = {
|
||||
showExternalPermissionRequired = false
|
||||
},
|
||||
onGranted = {
|
||||
showExternalPermissionRequired = false
|
||||
updateValue(RECORDER_MEDIA_SELECTED_VALUE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_default_label),
|
||||
icon = Icons.Default.Lock,
|
||||
onClick = {
|
||||
updateValue(null)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_dcim_label),
|
||||
icon = Icons.Default.PermMedia,
|
||||
onClick = {
|
||||
if (
|
||||
SUPPORTS_SCOPED_STORAGE ||
|
||||
PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
) {
|
||||
updateValue(RECORDER_MEDIA_SELECTED_VALUE)
|
||||
} else {
|
||||
showExternalPermissionRequired = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Column {
|
||||
SelectionButton(
|
||||
label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label),
|
||||
icon = Icons.Default.Folder,
|
||||
onClick = selectFolder,
|
||||
)
|
||||
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
MessageBox(
|
||||
type = MessageType.INFO,
|
||||
message = stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_minApiRequired, 8, 26),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Text(label)
|
||||
Box {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExternalPermissionRequiredDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onGranted: () -> Unit,
|
||||
) {
|
||||
PermissionRequester(
|
||||
icon = Icons.Default.PermMedia,
|
||||
permission = Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
onPermissionAvailable = onGranted,
|
||||
) { trigger ->
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.PermMedia,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_title),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_text),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = trigger,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_externalPermissionRequired_action_confirm),
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Cancel,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.dialog_close_cancel_label))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun splitPath(path: String): List<String> {
|
||||
return try {
|
||||
URLDecoder
|
||||
.decode(path, "UTF-8")
|
||||
.split(":", limit = 3)[2]
|
||||
.split("/")
|
||||
} catch (e: Exception) {
|
||||
listOf(path)
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DividerTitle(
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 32.dp),
|
||||
title: String,
|
||||
description: String,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.utils.IconResource
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.input.InputDialog
|
||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoRecorderBitrateTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
fun updateValue(bitRate: Int?) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setVideoRecorderSettings(
|
||||
it.videoRecorderSettings.setTargetedVideoBitRate(bitRate)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val notNumberLabel = stringResource(R.string.form_error_type_notNumber)
|
||||
InputDialog(
|
||||
state = showDialog,
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.ui_settings_option_videoTargetedBitrate_title),
|
||||
icon = IconSource(
|
||||
painter = IconResource.fromImageVector(Icons.Default.Tune).asPainterResource(),
|
||||
contentDescription = null,
|
||||
)
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = listOf(
|
||||
InputTextField(
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.ui_settings_option_videoTargetedBitrate_explanation),
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
text = if (settings.videoRecorderSettings.targetedVideoBitRate == null) "" else (settings.videoRecorderSettings.targetedVideoBitRate / 1000).toString(),
|
||||
validationListener = { text ->
|
||||
val bitRate = text?.toIntOrNull()
|
||||
|
||||
if (bitRate == null) {
|
||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||
}
|
||||
|
||||
ValidationResult.Valid
|
||||
},
|
||||
key = "bitrate",
|
||||
)
|
||||
),
|
||||
) { result ->
|
||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: return@InputSelection
|
||||
|
||||
updateValue(bitRate * 1000)
|
||||
}
|
||||
)
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_videoTargetedBitrate_title),
|
||||
description = stringResource(R.string.ui_settings_option_bitrate_description),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.Tune,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Button(
|
||||
onClick = showDialog::show,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(formatBitrate(settings.videoRecorderSettings.targetedVideoBitRate))
|
||||
}
|
||||
},
|
||||
extra = {
|
||||
ExampleListRoulette(
|
||||
items = VideoRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
||||
onItemSelected = ::updateValue,
|
||||
) { bitRate ->
|
||||
Text(formatBitrate(bitRate))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun formatBitrate(bitrate: Int?): String {
|
||||
return if (bitrate == null)
|
||||
stringResource(R.string.ui_settings_value_auto_label)
|
||||
else if (bitrate >= 1000 * 1000 && bitrate % (1000 * 1000) == 0)
|
||||
stringResource(
|
||||
R.string.format_mbps,
|
||||
bitrate / 1000 / 1000,
|
||||
)
|
||||
else if (bitrate >= 1000 && bitrate % 1000 == 0)
|
||||
stringResource(
|
||||
R.string.format_kbps,
|
||||
bitrate / 1000,
|
||||
)
|
||||
else
|
||||
stringResource(
|
||||
R.string.format_bps,
|
||||
bitrate,
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,127 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.utils.IconResource
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.input.InputDialog
|
||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
||||
import com.maxkeppeler.sheets.input.models.InputSelection
|
||||
import com.maxkeppeler.sheets.input.models.InputTextField
|
||||
import com.maxkeppeler.sheets.input.models.InputTextFieldType
|
||||
import com.maxkeppeler.sheets.input.models.ValidationResult
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoRecorderFrameRateTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
fun updateValue(frameRate: Int?) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setVideoRecorderSettings(
|
||||
it.videoRecorderSettings.setTargetFrameRate(frameRate)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val notNumberLabel = stringResource(R.string.form_error_type_notNumber)
|
||||
InputDialog(
|
||||
state = showDialog,
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.ui_settings_option_videoTargetedFrameRate_title),
|
||||
icon = IconSource(
|
||||
painter = IconResource.fromImageVector(Icons.Default.BrokenImage)
|
||||
.asPainterResource(),
|
||||
contentDescription = null,
|
||||
)
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = listOf(
|
||||
InputTextField(
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.ui_settings_option_videoTargetedFrameRate_explanation),
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
text = if (settings.videoRecorderSettings.targetFrameRate == null) "" else settings.videoRecorderSettings.targetFrameRate.toString(),
|
||||
validationListener = { text ->
|
||||
val frameRate = text?.toIntOrNull()
|
||||
|
||||
if (frameRate == null) {
|
||||
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||
}
|
||||
|
||||
ValidationResult.Valid
|
||||
},
|
||||
key = "framerate",
|
||||
)
|
||||
),
|
||||
) { result ->
|
||||
val frameRate = result.getString("framerate")?.toIntOrNull() ?: return@InputSelection
|
||||
|
||||
updateValue(frameRate)
|
||||
}
|
||||
)
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_videoTargetedFrameRate_title),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Button(
|
||||
onClick = showDialog::show,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
if (settings.videoRecorderSettings.targetFrameRate == null)
|
||||
Text(stringResource(R.string.ui_settings_value_auto_label))
|
||||
else
|
||||
Text(settings.videoRecorderSettings.targetFrameRate.toString())
|
||||
}
|
||||
},
|
||||
extra = {
|
||||
ExampleListRoulette(
|
||||
items = VideoRecorderSettings.EXAMPLE_FRAME_RATE_VALUES,
|
||||
onItemSelected = ::updateValue,
|
||||
) { frameRate ->
|
||||
Text(
|
||||
frameRate?.toString() ?: stringResource(R.string.ui_settings_value_auto_label)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||
|
||||
import android.media.MediaRecorder
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.HighQuality
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.VideoRecorderSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.ExampleListRoulette
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.utils.IconResource
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.input.models.InputHeader
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VideoRecorderQualityTile(
|
||||
settings: AppSettings,
|
||||
) {
|
||||
val QUALITY_NAME_TEXT_MAP = mapOf<Quality, String>(
|
||||
Quality.HIGHEST to stringResource(R.string.ui_settings_value_videoQuality_values_highest),
|
||||
Quality.UHD to stringResource(R.string.ui_settings_value_videoQuality_values_uhd),
|
||||
Quality.FHD to stringResource(R.string.ui_settings_value_videoQuality_values_fhd),
|
||||
Quality.HD to stringResource(R.string.ui_settings_value_videoQuality_values_hd),
|
||||
Quality.SD to stringResource(R.string.ui_settings_value_videoQuality_values_sd),
|
||||
Quality.LOWEST to stringResource(R.string.ui_settings_value_videoQuality_values_lowest),
|
||||
)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val showDialog = rememberUseCaseState()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
fun updateValue(quality: Quality?) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setVideoRecorderSettings(
|
||||
it.videoRecorderSettings.setQuality(quality)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListDialog(
|
||||
state = showDialog,
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.ui_settings_option_videoQualityTile_title),
|
||||
icon = IconSource(
|
||||
painter = IconResource.fromImageVector(Icons.Default.HighQuality)
|
||||
.asPainterResource(),
|
||||
contentDescription = null,
|
||||
),
|
||||
),
|
||||
selection = ListSelection.Single(
|
||||
showRadioButtons = true,
|
||||
options = VideoRecorderSettings.AVAILABLE_QUALITIES.map { quality ->
|
||||
ListOption(
|
||||
titleText = QUALITY_NAME_TEXT_MAP[quality]!!,
|
||||
selected = settings.videoRecorderSettings.quality == quality.toString(),
|
||||
)
|
||||
}.toList()
|
||||
) { index, _ ->
|
||||
val quality = VideoRecorderSettings.AVAILABLE_QUALITIES[index]
|
||||
|
||||
updateValue(quality)
|
||||
},
|
||||
)
|
||||
SettingsTile(
|
||||
title = stringResource(R.string.ui_settings_option_videoQualityTile_title),
|
||||
leading = {
|
||||
Icon(
|
||||
Icons.Default.HighQuality,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Button(
|
||||
onClick = showDialog::show,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
QUALITY_NAME_TEXT_MAP[settings.videoRecorderSettings.getQuality()]
|
||||
?: stringResource(
|
||||
R.string.ui_settings_value_auto_label
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
extra = {
|
||||
ExampleListRoulette(
|
||||
items = listOf(null),
|
||||
onItemSelected = ::updateValue,
|
||||
) {
|
||||
Text(stringResource(R.string.ui_settings_value_auto_label))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FolderBreadcrumbs(
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle? = null,
|
||||
folders: Iterable<String>,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
folders.forEachIndexed { index, folder ->
|
||||
if (index != 0) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = folder,
|
||||
modifier = Modifier
|
||||
.then(modifier),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = textStyle ?: MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.SUPPORTED_LOCALES
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||
import app.myzel394.alibi.ui.utils.IconResource
|
||||
|
@ -0,0 +1,173 @@
|
||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun Preview(
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color,
|
||||
primaryColor: Color,
|
||||
textColor: Color,
|
||||
onSelect: () -> Unit,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(200.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(width = 1.dp, color = textColor, shape = RoundedCornerShape(10.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable { onSelect() },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(30.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.background(primaryColor)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.background(primaryColor)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = primaryColor,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(40.dp)
|
||||
.height(6.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.background(primaryColor)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(75.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.background(textColor)
|
||||
)
|
||||
}
|
||||
Box {}
|
||||
}
|
||||
if (isSelected) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(30.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeSelector() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
val settings = dataStore
|
||||
.data
|
||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||
.value
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Preview(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
backgroundColor = Color(0xFFF0F0F0),
|
||||
primaryColor = Color(0xFFAAAAAA),
|
||||
textColor = Color(0xFFCCCCCC),
|
||||
onSelect = {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setTheme(AppSettings.Theme.LIGHT)
|
||||
}
|
||||
}
|
||||
},
|
||||
isSelected = settings.theme == AppSettings.Theme.LIGHT,
|
||||
)
|
||||
Preview(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
backgroundColor = Color(0xFF444444),
|
||||
primaryColor = Color(0xFF888888),
|
||||
textColor = Color(0xFF606060),
|
||||
onSelect = {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setTheme(AppSettings.Theme.DARK)
|
||||
}
|
||||
}
|
||||
},
|
||||
isSelected = settings.theme == AppSettings.Theme.DARK,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.ui.utils.IconResource
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.duration.DurationDialog
|
||||
import com.maxkeppeler.sheets.duration.models.DurationConfig
|
||||
import com.maxkeppeler.sheets.duration.models.DurationFormat
|
||||
import com.maxkeppeler.sheets.duration.models.DurationSelection
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val MINUTES_1 = 1000 * 60 * 1L
|
||||
const val MINUTES_5 = 1000 * 60 * 5L
|
||||
const val MINUTES_15 = 1000 * 60 * 15L
|
||||
const val MINUTES_30 = 1000 * 60 * 30L
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MaxDurationSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val OPTIONS = mapOf<Long, String>(
|
||||
MINUTES_1 to stringResource(R.string.ui_welcome_timeSettings_values_1min),
|
||||
MINUTES_5 to stringResource(R.string.ui_welcome_timeSettings_values_5min),
|
||||
MINUTES_15 to stringResource(R.string.ui_welcome_timeSettings_values_15min),
|
||||
MINUTES_30 to stringResource(R.string.ui_welcome_timeSettings_values_30min),
|
||||
)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val dataStore = LocalContext.current.dataStore
|
||||
|
||||
var selectedDuration by rememberSaveable { mutableLongStateOf(MINUTES_15) };
|
||||
|
||||
// Make sure appSettings is updated properly
|
||||
LaunchedEffect(selectedDuration) {
|
||||
scope.launch {
|
||||
dataStore.updateData {
|
||||
it.setMaxDuration(selectedDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.then(modifier),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
for ((duration, label) in OPTIONS) {
|
||||
val a11yLabel = stringResource(
|
||||
R.string.a11y_selectValue,
|
||||
label
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = a11yLabel
|
||||
}
|
||||
.clickable {
|
||||
selectedDuration = duration
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedDuration == duration,
|
||||
onClick = { selectedDuration = duration },
|
||||
)
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
let {
|
||||
val showDialog = rememberUseCaseState()
|
||||
val label = stringResource(R.string.ui_welcome_timeSettings_values_custom)
|
||||
val selected = selectedDuration !in OPTIONS.keys
|
||||
|
||||
DurationDialog(
|
||||
state = showDialog,
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.ui_settings_option_maxDuration_title),
|
||||
icon = IconSource(
|
||||
painter = IconResource.fromImageVector(Icons.Default.Timer)
|
||||
.asPainterResource(),
|
||||
contentDescription = null,
|
||||
)
|
||||
),
|
||||
selection = DurationSelection { newTimeInSeconds ->
|
||||
selectedDuration = newTimeInSeconds * 1000L
|
||||
},
|
||||
config = DurationConfig(
|
||||
timeFormat = DurationFormat.HH_MM,
|
||||
currentTime = selectedDuration / 1000,
|
||||
minTime = 60,
|
||||
maxTime = 23 * 60 * 60 + 60 * 59,
|
||||
)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = label
|
||||
}
|
||||
.clickable {
|
||||
showDialog.show()
|
||||
}
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.padding(2.dp),
|
||||
tint = if (selected) MaterialTheme.colorScheme.primary else contentColorFor(
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
)
|
||||
if (selected) {
|
||||
val totalMinutes = selectedDuration / 1000 / 60
|
||||
val minutes = totalMinutes % 60
|
||||
val hours = (totalMinutes / 60).toInt()
|
||||
|
||||
Text(
|
||||
text = when (hours) {
|
||||
0 -> stringResource(
|
||||
R.string.ui_welcome_timeSettings_values_customFormat_mm,
|
||||
minutes
|
||||
)
|
||||
|
||||
1 -> stringResource(
|
||||
R.string.ui_welcome_timeSettings_values_customFormat_h_mm,
|
||||
minutes
|
||||
)
|
||||
|
||||
else -> stringResource(
|
||||
R.string.ui_welcome_timeSettings_values_customFormat_hh_mm,
|
||||
hours,
|
||||
minutes
|
||||
)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.ui_welcome_timeSettings_values_custom),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.PermMedia
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
||||
|
||||
const val CUSTOM_FOLDER = "custom"
|
||||
|
||||
@Composable
|
||||
fun SaveFolderSelection(
|
||||
modifier: Modifier = Modifier,
|
||||
saveFolder: String?,
|
||||
isLowOnStorage: Boolean,
|
||||
onSaveFolderChange: (String?) -> Unit,
|
||||
) {
|
||||
@Composable
|
||||
fun createModifier(a11yLabel: String, onClick: () -> Unit) =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = a11yLabel
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(16.dp)
|
||||
.padding(end = 8.dp)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.then(modifier),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
let {
|
||||
val label = stringResource(R.string.ui_welcome_saveFolder_values_internal)
|
||||
val a11yLabel = stringResource(
|
||||
R.string.a11y_selectValue,
|
||||
label
|
||||
)
|
||||
val folder = null
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = createModifier(a11yLabel) {
|
||||
onSaveFolderChange(folder)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = saveFolder == folder,
|
||||
onClick = { onSaveFolderChange(folder) },
|
||||
)
|
||||
Text(label)
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
let {
|
||||
val label = stringResource(R.string.ui_welcome_saveFolder_values_media)
|
||||
val a11yLabel = stringResource(
|
||||
R.string.a11y_selectValue,
|
||||
label
|
||||
)
|
||||
val folder = RECORDER_MEDIA_SELECTED_VALUE
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = createModifier(a11yLabel) {
|
||||
onSaveFolderChange(folder)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = saveFolder == folder,
|
||||
onClick = { onSaveFolderChange(folder) },
|
||||
)
|
||||
Text(label)
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.PermMedia,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
let {
|
||||
val label = stringResource(R.string.ui_welcome_saveFolder_values_custom)
|
||||
val a11yLabel = stringResource(
|
||||
R.string.a11y_selectValue,
|
||||
label
|
||||
)
|
||||
val folder = CUSTOM_FOLDER
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = createModifier(a11yLabel) {
|
||||
onSaveFolderChange(folder)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = saveFolder == folder,
|
||||
onClick = { onSaveFolderChange(folder) },
|
||||
)
|
||||
Text(label)
|
||||
}
|
||||
Icon(
|
||||
Icons.Default.Folder,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
}
|
||||
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
|
||||
fontSize = MaterialTheme.typography.titleSmall.fontSize,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.ui_minApiRequired, 8, 26),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLowOnStorage && saveFolder == null)
|
||||
MessageBox(
|
||||
type = MessageType.ERROR,
|
||||
message = stringResource(R.string.ui_welcome_saveFolder_externalRequired)
|
||||
)
|
||||
else
|
||||
Box(
|
||||
modifier = Modifier.widthIn(max = 400.dp)
|
||||
) {
|
||||
MessageBox(
|
||||
type = MessageType.INFO,
|
||||
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
|
||||
density = VisualDensity.DENSE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -64,6 +64,7 @@ fun ExplanationPage(
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user