mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
Compare commits
735 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 | ||
![]() |
fc643040c6 | ||
![]() |
da29aa62bd | ||
![]() |
c55c8d8e57 |
9
.github/actions/prepare-keystore/action.yml
vendored
9
.github/actions/prepare-keystore/action.yml
vendored
@ -19,16 +19,13 @@ runs:
|
|||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Write Keystore file 🗄️
|
- name: Write Keystore file 🗄️
|
||||||
id: android_keystore
|
shell: bash
|
||||||
uses: timheuer/base64-to-file@v1.0.3
|
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
|
||||||
with:
|
|
||||||
fileName: key.jks
|
|
||||||
encodedString: ${{ inputs.keyStoreBase64 }}
|
|
||||||
|
|
||||||
- name: Write Keystore properties 🗝️
|
- name: Write Keystore properties 🗝️
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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 "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
||||||
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
||||||
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> 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:
|
debug-builds:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
java-version: 19
|
java-version: 21
|
||||||
cache: "gradle"
|
cache: "gradle"
|
||||||
|
|
||||||
- name: Compile
|
- name: Compile
|
||||||
@ -23,6 +23,7 @@ jobs:
|
|||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: gradle/wrapper-validation-action@v2
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -21,10 +23,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: "17.x"
|
java-version: 21
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -37,3 +39,14 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: app/build/outputs/apk/release/*.apk
|
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:
|
on:
|
||||||
release:
|
release:
|
||||||
@ -10,7 +10,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: gradle/wrapper-validation-action@v2
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -21,10 +23,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: "17.x"
|
java-version: 21
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -39,4 +41,4 @@ jobs:
|
|||||||
track: production
|
track: production
|
||||||
status: inProgress
|
status: inProgress
|
||||||
inAppUpdatePriority: 2
|
inAppUpdatePriority: 2
|
||||||
userFraction: 0.33
|
userFraction: 0.2
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/.idea
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
/.idea/libraries
|
/.idea/libraries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
|
28
README.md
28
README.md
@ -3,18 +3,21 @@
|
|||||||
# Alibi
|
# Alibi
|
||||||
|
|
||||||
<p float="left" align="center">
|
<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/01.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
|
<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>
|
</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.
|
Everything is completely configurable. No internet connection required.
|
||||||
|
|
||||||
# Download
|
# 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)
|
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
||||||
|
|
||||||
# Supporting Alibi
|
# Supporting Alibi
|
||||||
@ -27,16 +30,13 @@ Add a new feature or fix bugs.
|
|||||||
|
|
||||||
## Add translations
|
## 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
|
## Donate
|
||||||
|
|
||||||
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
|
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 can totally focus on Alibi and my other open
|
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
|
||||||
source projects. :)
|
source projects. :)
|
||||||
|
|
||||||
You can donate via:
|
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).
|
||||||
|
|
||||||
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
|
|
||||||
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
|
|
||||||
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
|
|
||||||
|
@ -35,8 +35,8 @@ android {
|
|||||||
applicationId "app.myzel394.alibi"
|
applicationId "app.myzel394.alibi"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 5
|
versionCode 16
|
||||||
versionName "0.2.2"
|
versionName "0.5.3"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -78,9 +78,11 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose true
|
||||||
|
buildConfig = true
|
||||||
|
viewBinding = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion '1.5.1'
|
kotlinCompilerExtensionVersion '1.5.10'
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
resources {
|
resources {
|
||||||
@ -90,41 +92,60 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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 platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
implementation 'androidx.activity:activity-compose:1.9.1'
|
||||||
implementation platform('androidx.compose:compose-bom:2022.10.00')
|
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'
|
||||||
implementation 'androidx.compose.ui:ui-graphics'
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||||
implementation "androidx.compose.material:material-icons-extended:1.5.1"
|
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
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'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
|
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
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'
|
implementation 'com.google.dagger:hilt-android:2.49'
|
||||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
|
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
|
||||||
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
|
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"
|
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:core:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration: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:list:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:input: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,10 +2,39 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<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.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<application
|
||||||
android:name=".UpdateSettingsApp"
|
android:name=".UpdateSettingsApp"
|
||||||
@ -17,6 +46,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Alibi"
|
android:theme="@style/Theme.Alibi"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@ -37,7 +67,13 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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 -->
|
<!-- Change locale for Android <= 12 -->
|
||||||
<service
|
<service
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
package app.myzel394.alibi
|
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.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.core.view.WindowCompat
|
||||||
import androidx.datastore.dataStore
|
import androidx.datastore.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettingsSerializer
|
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.Navigation
|
||||||
import app.myzel394.alibi.ui.theme.AlibiTheme
|
import app.myzel394.alibi.ui.theme.AlibiTheme
|
||||||
|
|
||||||
@ -26,7 +31,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
AlibiTheme {
|
AlibiTheme {
|
||||||
Navigation()
|
LockedAppHandlers()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.background
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
AsLockedApp {
|
||||||
|
Navigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,45 @@
|
|||||||
package app.myzel394.alibi.db
|
package app.myzel394.alibi.db
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import androidx.camera.video.Quality
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
import androidx.camera.video.QualitySelector
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import app.myzel394.alibi.R
|
||||||
import kotlinx.coroutines.delay
|
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 kotlinx.serialization.Serializable
|
||||||
import java.io.File
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppSettings(
|
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 hasSeenOnboarding: Boolean = false,
|
||||||
val showAdvancedSettings: 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 {
|
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
||||||
return copy(showAdvancedSettings = showAdvancedSettings)
|
return copy(showAdvancedSettings = showAdvancedSettings)
|
||||||
@ -25,123 +49,166 @@ data class AppSettings(
|
|||||||
return copy(audioRecorderSettings = audioRecorderSettings)
|
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 {
|
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
|
||||||
return copy(hasSeenOnboarding = hasSeenOnboarding)
|
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 {
|
companion object {
|
||||||
fun getDefaultInstance(): AppSettings = AppSettings()
|
fun getDefaultInstance(): AppSettings = AppSettings()
|
||||||
|
|
||||||
|
fun fromExportedString(data: String): AppSettings {
|
||||||
|
return Json.decodeFromString(
|
||||||
|
serializer(),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LastRecording(
|
data class RecordingInformation(
|
||||||
val folderPath: String,
|
val folderPath: String,
|
||||||
@Serializable(with = LocalDateTimeSerializer::class)
|
@Serializable(with = LocalDateTimeSerializer::class)
|
||||||
val recordingStart: LocalDateTime,
|
val recordingStart: LocalDateTime,
|
||||||
|
val batchesAmount: Int,
|
||||||
val maxDuration: Long,
|
val maxDuration: Long,
|
||||||
val intervalDuration: Long,
|
val intervalDuration: Long,
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
val forceExactMaxDuration: Boolean,
|
val type: Type,
|
||||||
) {
|
) {
|
||||||
val fileFolder: File
|
fun hasRecordingsAvailable(context: Context): Boolean =
|
||||||
get() = File(folderPath)
|
when (type) {
|
||||||
|
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
|
||||||
|
.hasRecordingsAvailable()
|
||||||
|
|
||||||
val filePaths: List<File>
|
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
|
||||||
get() =
|
.hasRecordingsAvailable()
|
||||||
File(folderPath).listFiles()?.filter {
|
}
|
||||||
val name = it.nameWithoutExtension
|
|
||||||
|
|
||||||
name.toIntOrNull() != null
|
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
|
||||||
}?.toList() ?: emptyList()
|
return when (filenameFormat) {
|
||||||
|
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
|
||||||
val hasRecordingAvailable: Boolean
|
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
|
||||||
get() = filePaths.isNotEmpty()
|
getFullDuration() / 1000
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
throw Exception("Failed to strip concatenated audio")
|
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
fun getFullDuration(): Long {
|
||||||
val paths = filePaths.joinToString("|")
|
// This is not accurate, since the last batch may be shorter than the others
|
||||||
val fileName = recordingStart
|
// but it's good enough
|
||||||
.format(ISO_DATE_TIME)
|
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
|
||||||
.toString()
|
}
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "_")
|
|
||||||
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
|
|
||||||
|
|
||||||
if (outputFile.exists() && !forceConcatenation) {
|
enum class Type {
|
||||||
return outputFile
|
AUDIO,
|
||||||
}
|
VIDEO,
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AudioRecorderSettings(
|
data class AudioRecorderSettings(
|
||||||
val maxDuration: Long = 30 * 60 * 1000L,
|
|
||||||
// 60 seconds
|
|
||||||
val intervalDuration: Long = 60 * 1000L,
|
|
||||||
val forceExactMaxDuration: Boolean = true,
|
|
||||||
// 320 Kbps
|
// 320 Kbps
|
||||||
val bitRate: Int = 320000,
|
val bitRate: Int = 320000,
|
||||||
val samplingRate: Int? = null,
|
val samplingRate: Int? = null,
|
||||||
val outputFormat: Int? = null,
|
val outputFormat: Int? = null,
|
||||||
val encoder: Int? = null,
|
val encoder: Int? = null,
|
||||||
|
val showAllMicrophones: Boolean = false,
|
||||||
) {
|
) {
|
||||||
fun getOutputFormat(): Int {
|
fun getOutputFormat(): Int {
|
||||||
if (outputFormat != null) {
|
if (outputFormat != null) {
|
||||||
@ -154,7 +221,7 @@ data class AudioRecorderSettings(
|
|||||||
else MediaRecorder.OutputFormat.THREE_GPP
|
else MediaRecorder.OutputFormat.THREE_GPP
|
||||||
}
|
}
|
||||||
|
|
||||||
return when(encoder) {
|
return when (encoder) {
|
||||||
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
||||||
@ -167,6 +234,7 @@ data class AudioRecorderSettings(
|
|||||||
MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaRecorder.AudioEncoder.OPUS -> {
|
MediaRecorder.AudioEncoder.OPUS -> {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
MediaRecorder.OutputFormat.OGG
|
MediaRecorder.OutputFormat.OGG
|
||||||
@ -174,11 +242,12 @@ data class AudioRecorderSettings(
|
|||||||
MediaRecorder.OutputFormat.AAC_ADTS
|
MediaRecorder.OutputFormat.AAC_ADTS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> MediaRecorder.OutputFormat.DEFAULT
|
else -> MediaRecorder.OutputFormat.DEFAULT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMimeType(): String = when(getOutputFormat()) {
|
fun getMimeType(): String = when (getOutputFormat()) {
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
||||||
@ -190,7 +259,7 @@ data class AudioRecorderSettings(
|
|||||||
else -> "audio/3gpp"
|
else -> "audio/3gpp"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSamplingRate(): Int = samplingRate ?: when(getOutputFormat()) {
|
fun getSamplingRate(): Int = samplingRate ?: when (getOutputFormat()) {
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
||||||
@ -202,26 +271,12 @@ data class AudioRecorderSettings(
|
|||||||
else -> 48000
|
else -> 48000
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getEncoder(): Int = encoder ?:
|
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
MediaRecorder.AudioEncoder.AAC
|
||||||
MediaRecorder.AudioEncoder.AAC
|
else
|
||||||
else
|
MediaRecorder.AudioEncoder.AMR_NB
|
||||||
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 setBitRate(bitRate: Int): AudioRecorderSettings {
|
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||||
println("bitRate: $bitRate")
|
|
||||||
if (bitRate !in 1000..320000) {
|
if (bitRate !in 1000..320000) {
|
||||||
throw Exception("Bit rate must be between 1000 and 320000")
|
throw Exception("Bit rate must be between 1000 and 320000")
|
||||||
}
|
}
|
||||||
@ -253,20 +308,8 @@ data class AudioRecorderSettings(
|
|||||||
return copy(encoder = encoder)
|
return copy(encoder = encoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||||
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
|
return copy(showAllMicrophones = showAllMicrophones)
|
||||||
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 isEncoderCompatible(encoder: Int): Boolean {
|
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||||
@ -279,17 +322,31 @@ data class AudioRecorderSettings(
|
|||||||
return supportedFormats.contains(outputFormat)
|
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 {
|
companion object {
|
||||||
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
||||||
val EXAMPLE_MAX_DURATIONS = listOf(
|
val EXAMPLE_MAX_DURATIONS = listOf(
|
||||||
|
1 * 60 * 1000L,
|
||||||
|
5 * 60 * 1000L,
|
||||||
15 * 60 * 1000L,
|
15 * 60 * 1000L,
|
||||||
30 * 60 * 1000L,
|
30 * 60 * 1000L,
|
||||||
60 * 60 * 1000L,
|
60 * 60 * 1000L,
|
||||||
2 * 60 * 60 * 1000L,
|
|
||||||
3 * 60 * 60 * 1000L,
|
|
||||||
)
|
)
|
||||||
val EXAMPLE_DURATION_TIMES = listOf(
|
val EXAMPLE_DURATION_TIMES = listOf(
|
||||||
60 * 1000L,
|
60 * 1000L,
|
||||||
|
60 * 2 * 1000L,
|
||||||
60 * 5 * 1000L,
|
60 * 5 * 1000L,
|
||||||
60 * 10 * 1000L,
|
60 * 10 * 1000L,
|
||||||
60 * 15 * 1000L,
|
60 * 15 * 1000L,
|
||||||
@ -387,3 +444,174 @@ data class AudioRecorderSettings(
|
|||||||
}).toMap()
|
}).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.io.OutputStream
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class AppSettingsSerializer: Serializer<AppSettings> {
|
class AppSettingsSerializer : Serializer<AppSettings> {
|
||||||
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
||||||
|
|
||||||
override suspend fun readFrom(input: InputStream): AppSettings {
|
override suspend fun readFrom(input: InputStream): AppSettings {
|
||||||
@ -39,8 +39,9 @@ class AppSettingsSerializer: Serializer<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
|
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||||
return LocalDateTime.parse(decoder.decodeString())
|
return LocalDateTime.parse(decoder.decodeString())
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
package app.myzel394.alibi.enums
|
package app.myzel394.alibi.enums
|
||||||
|
|
||||||
enum class RecorderState {
|
enum class RecorderState {
|
||||||
IDLE,
|
STOPPED,
|
||||||
RECORDING,
|
RECORDING,
|
||||||
PAUSED,
|
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
|
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
|
||||||
import android.media.MediaRecorder.OnErrorListener
|
import android.media.MediaRecorder.OnErrorListener
|
||||||
import android.os.Build
|
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 amplitudesAmount = 1000
|
||||||
|
|
||||||
|
var selectedMicrophone: MicrophoneInfo? = null
|
||||||
|
|
||||||
var recorder: MediaRecorder? = null
|
var recorder: MediaRecorder? = null
|
||||||
private set
|
private set
|
||||||
var onError: () -> Unit = {}
|
|
||||||
|
|
||||||
val filePath: String
|
// Callbacks
|
||||||
get() = "$folder/$counter.${settings!!.fileExtension}"
|
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||||
|
var onMicrophoneDisconnected: () -> Unit = {}
|
||||||
private fun createRecorder(): MediaRecorder {
|
var onMicrophoneReconnected: () -> Unit = {}
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startNewCycle() {
|
override fun startNewCycle() {
|
||||||
super.startNewCycle()
|
super.startNewCycle()
|
||||||
@ -50,6 +47,7 @@ class AudioRecorderService: IntervalRecorderService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
|
startAudioDevice()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recorder = newRecorder
|
recorder = newRecorder
|
||||||
@ -59,21 +57,48 @@ class AudioRecorderService: IntervalRecorderService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
super.start()
|
||||||
|
|
||||||
|
createAmplitudesTimer()
|
||||||
|
registerMicrophoneListener()
|
||||||
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
super.pause()
|
super.pause()
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override suspend fun stop() {
|
||||||
super.stop()
|
|
||||||
|
|
||||||
resetRecorder()
|
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 {
|
return try {
|
||||||
recorder!!.maxAmplitude
|
recorder!!.maxAmplitude
|
||||||
} catch (error: IllegalStateException) {
|
} catch (error: IllegalStateException) {
|
||||||
@ -82,4 +107,209 @@ class AudioRecorderService: IntervalRecorderService() {
|
|||||||
0
|
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
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
import android.content.Context
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import android.media.MediaRecorder
|
import app.myzel394.alibi.helpers.BatchesFolder
|
||||||
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 java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
||||||
private var job = SupervisorJob()
|
RecorderService() {
|
||||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
protected var counter = 0L
|
||||||
|
|
||||||
protected var counter = 0
|
|
||||||
private set
|
private set
|
||||||
protected lateinit var folder: File
|
|
||||||
var settings: Settings? = null
|
// Tracks the index of the currently locked file
|
||||||
protected set
|
private var lockedIndex: Long? = null
|
||||||
|
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
private lateinit var cycleTimer: ScheduledExecutorService
|
private lateinit var cycleTimer: ScheduledExecutorService
|
||||||
|
|
||||||
fun createLastRecording(): LastRecording = LastRecording(
|
abstract var batchesFolder: B
|
||||||
folderPath = folder.absolutePath,
|
|
||||||
recordingStart = recordingStart,
|
var onBatchesFolderNotAccessible: () -> Unit = {}
|
||||||
maxDuration = settings!!.maxDuration,
|
|
||||||
fileExtension = settings!!.fileExtension,
|
abstract fun getRecordingInformation(): I
|
||||||
intervalDuration = settings!!.intervalDuration,
|
|
||||||
forceExactMaxDuration = settings!!.forceExactMaxDuration,
|
// 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
|
// Make overrideable
|
||||||
open fun startNewCycle() {
|
open fun startNewCycle() {
|
||||||
@ -50,103 +50,56 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
|||||||
private fun createTimer() {
|
private fun createTimer() {
|
||||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
{
|
::startNewCycle,
|
||||||
startNewCycle()
|
|
||||||
},
|
|
||||||
0,
|
0,
|
||||||
settings!!.intervalDuration,
|
settings.intervalDuration,
|
||||||
TimeUnit.MILLISECONDS
|
TimeUnit.MILLISECONDS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRandomFileFolder(): String {
|
|
||||||
// uuid
|
|
||||||
val folder = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
return "${externalCacheDir!!.absolutePath}/$folder"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
override fun start() {
|
||||||
super.start()
|
super.start()
|
||||||
|
|
||||||
folder = File(getRandomFileFolder())
|
batchesFolder.initFolders()
|
||||||
folder.mkdirs()
|
|
||||||
|
|
||||||
scope.launch {
|
if (!batchesFolder.checkIfFolderIsAccessible()) {
|
||||||
dataStore.data.collectLatest { preferenceSettings ->
|
onBatchesFolderNotAccessible()
|
||||||
if (settings == null) {
|
|
||||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
|
||||||
|
|
||||||
createTimer()
|
throw AvoidErrorDialogError()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
|
super.pause()
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resume() {
|
override fun resume() {
|
||||||
createTimer()
|
|
||||||
|
|
||||||
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
|
|
||||||
// amplitudes
|
|
||||||
super.resume()
|
super.resume()
|
||||||
|
createTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override suspend fun stop() {
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
|
batchesFolder.cleanup()
|
||||||
|
super.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAllRecordings() {
|
||||||
|
batchesFolder.deleteRecordings()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteOldRecordings() {
|
private fun deleteOldRecordings() {
|
||||||
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||||
val earliestCounter = counter - timeMultiplier
|
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
|
||||||
|
|
||||||
folder.listFiles()?.forEach { file ->
|
if (earliestCounter <= 0) {
|
||||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
return
|
||||||
|
|
||||||
if (fileCounter < earliestCounter) {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
data class Settings(
|
batchesFolder.deleteRecordings(0..earliestCounter)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
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.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import app.myzel394.alibi.MainActivity
|
import androidx.lifecycle.LifecycleService
|
||||||
import app.myzel394.alibi.NotificationHelper
|
import app.myzel394.alibi.NotificationHelper
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.LocalDateTime
|
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.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
abstract class RecorderService: Service() {
|
abstract class RecorderService : LifecycleService() {
|
||||||
private val binder = RecorderBinder()
|
private val binder = RecorderBinder()
|
||||||
|
|
||||||
private var isPaused: Boolean = false
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
lateinit var recordingStart: LocalDateTime
|
lateinit var recordingStart: LocalDateTime
|
||||||
private set
|
private set
|
||||||
|
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||||
|
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
||||||
|
|
||||||
var state = RecorderState.IDLE
|
var state = RecorderState.IDLE
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var onStateChange: ((RecorderState) -> Unit)? = null
|
var onStateChange: ((RecorderState) -> Unit)? = null
|
||||||
|
var onError: () -> Unit = {}
|
||||||
|
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
var recordingTime = 0L
|
var recordingTime = 0L
|
||||||
private set
|
private set
|
||||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
|
||||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
|
||||||
|
|
||||||
protected abstract fun start()
|
protected open fun start() {
|
||||||
protected abstract fun pause()
|
createRecordingTimeTimer()
|
||||||
protected abstract fun resume()
|
}
|
||||||
protected abstract fun stop()
|
|
||||||
|
|
||||||
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
|
"init" -> {
|
||||||
|
notificationDetails = intent.getStringExtra("notificationDetails")?.let {
|
||||||
|
Json.decodeFromString(
|
||||||
|
RecorderNotificationHelper.NotificationDetails.serializer(),
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"changeState" -> {
|
"changeState" -> {
|
||||||
val newState = intent.getStringExtra("newState")?.let {
|
val newState = intent.getStringExtra("newState")?.let {
|
||||||
RecorderState.valueOf(it)
|
RecorderState.valueOf(it)
|
||||||
} ?: RecorderState.IDLE
|
} ?: RecorderState.STOPPED
|
||||||
changeState(newState)
|
changeState(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,7 +120,7 @@ abstract class RecorderService: Service() {
|
|||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class RecorderBinder: Binder() {
|
inner class RecorderBinder : Binder() {
|
||||||
fun getService(): RecorderService = this@RecorderService
|
fun getService(): RecorderService = this@RecorderService
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,16 +128,19 @@ abstract class RecorderService: Service() {
|
|||||||
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
{
|
{
|
||||||
recordingTime += 1000
|
recordingTime += 1
|
||||||
onRecordingTimeChange?.invoke(recordingTime)
|
onRecordingTimeChange?.invoke(recordingTime)
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
1000,
|
1,
|
||||||
TimeUnit.MILLISECONDS
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
fun changeState(newState: RecorderState) {
|
fun changeState(newState: RecorderState) {
|
||||||
if (state == newState) {
|
if (state == newState) {
|
||||||
@ -91,151 +153,57 @@ abstract class RecorderService: Service() {
|
|||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
resume()
|
resume()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
} else {
|
|
||||||
start()
|
|
||||||
}
|
}
|
||||||
|
// `start` is handled by `startRecording`
|
||||||
}
|
}
|
||||||
RecorderState.PAUSED -> {
|
|
||||||
pause()
|
RecorderState.PAUSED -> pause()
|
||||||
isPaused = true
|
|
||||||
}
|
else -> {}
|
||||||
RecorderState.IDLE -> {
|
|
||||||
stop()
|
|
||||||
onDestroy()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (newState) {
|
// Update notification
|
||||||
RecorderState.RECORDING -> {
|
|
||||||
createRecordingTimeTimer()
|
|
||||||
}
|
|
||||||
RecorderState.PAUSED, RecorderState.IDLE -> {
|
|
||||||
recordingTimeTimer.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
arrayOf(
|
arrayOf(
|
||||||
RecorderState.RECORDING,
|
RecorderState.RECORDING,
|
||||||
RecorderState.PAUSED
|
RecorderState.PAUSED
|
||||||
).contains(newState) &&
|
).contains(newState) &&
|
||||||
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
|
PermissionHelper.hasGranted(this, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
){
|
) {
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
NotificationManagerCompat.from(this).notify(
|
NotificationManagerCompat.from(this).notify(
|
||||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||||
notification
|
notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStateChange?.invoke(newState)
|
onStateChange?.invoke(newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be immediately called after creating the service!
|
protected fun getNotificationHelper(): RecorderNotificationHelper {
|
||||||
fun startRecording() {
|
return RecorderNotificationHelper(this, notificationDetails)
|
||||||
recordingStart = LocalDateTime.now()
|
|
||||||
|
|
||||||
val notification = buildStartNotification()
|
|
||||||
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
|
|
||||||
|
|
||||||
// Start
|
|
||||||
changeState(RecorderState.RECORDING)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
private fun buildNotification(): Notification {
|
||||||
super.onDestroy()
|
val notificationHelper = getNotificationHelper()
|
||||||
|
|
||||||
changeState(RecorderState.IDLE)
|
return when (state) {
|
||||||
|
RecorderState.RECORDING -> {
|
||||||
|
notificationHelper.buildRecordingNotification(recordingTime)
|
||||||
|
}
|
||||||
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
RecorderState.PAUSED -> {
|
||||||
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
notificationHelper.buildPausedNotification(recordingStart)
|
||||||
stopSelf()
|
}
|
||||||
|
|
||||||
|
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 {
|
// Throw this error if you show a dialog yourself.
|
||||||
return PendingIntent.getService(
|
// This will prevent the service from showing their generic error dialog.
|
||||||
this,
|
class AvoidErrorDialogError : RuntimeException()
|
||||||
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()`")
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -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
|
package app.myzel394.alibi.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
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 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.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.LastRecording
|
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
import app.myzel394.alibi.ui.enums.Screen
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
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.SettingsScreen
|
||||||
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
||||||
|
|
||||||
const val SCALE_IN = 1.25f
|
const val SCALE_IN = 1.25f
|
||||||
|
const val DEBUG_SKIP_WELCOME = false;
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Navigation(
|
fun Navigation(
|
||||||
audioRecorder: AudioRecorderModel = viewModel()
|
audioRecorder: AudioRecorderModel = viewModel(),
|
||||||
|
videoRecorder: VideoRecorderModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -44,9 +47,11 @@ fun Navigation(
|
|||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
audioRecorder.bindToService(context)
|
audioRecorder.bindToService(context)
|
||||||
|
videoRecorder.bindToService(context)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
audioRecorder.unbindFromService(context)
|
audioRecorder.unbindFromService(context)
|
||||||
|
videoRecorder.unbindFromService(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,10 +59,18 @@ fun Navigation(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
navController = navController,
|
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) {
|
composable(Screen.Welcome.route) {
|
||||||
WelcomeScreen(navController = navController)
|
WelcomeScreen(
|
||||||
|
onNavigateToAudioRecorderScreen = {
|
||||||
|
val mainHandler = ContextCompat.getMainExecutor(context)
|
||||||
|
|
||||||
|
mainHandler.execute {
|
||||||
|
navController.navigate(Screen.AudioRecorder.route)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
Screen.AudioRecorder.route,
|
Screen.AudioRecorder.route,
|
||||||
@ -71,9 +84,13 @@ fun Navigation(
|
|||||||
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
AudioRecorder(
|
RecorderScreen(
|
||||||
navController = navController,
|
onNavigateToSettingsScreen = {
|
||||||
|
navController.navigate(Screen.Settings.route)
|
||||||
|
},
|
||||||
audioRecorder = audioRecorder,
|
audioRecorder = audioRecorder,
|
||||||
|
videoRecorder = videoRecorder,
|
||||||
|
settings = settings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -86,8 +103,43 @@ fun Navigation(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
navController = navController,
|
onBackNavigate = navController::popBackStack,
|
||||||
|
onNavigateToCustomRecordingNotifications = {
|
||||||
|
navController.navigate(Screen.CustomRecordingNotifications.route)
|
||||||
|
},
|
||||||
|
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
|
||||||
audioRecorder = audioRecorder,
|
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.Canvas
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -29,7 +29,7 @@ fun AudioVisualizer(
|
|||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
val boxWidth = width / amplitudes.size
|
val boxWidth = width / amplitudes.size
|
||||||
|
|
||||||
amplitudes.forEachIndexed {index, amplitude ->
|
amplitudes.forEachIndexed { index, amplitude ->
|
||||||
val x = boxWidth * index
|
val x = boxWidth * index
|
||||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||||
val boxHeight = height * amplitudePercentage
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -11,6 +11,7 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -28,10 +29,10 @@ fun ConfirmDeletionDialog(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
|
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
|
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@ -40,12 +41,13 @@ fun ConfirmDeletionDialog(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
onClick = {
|
onClick = {
|
||||||
onConfirm()
|
onConfirm()
|
||||||
},
|
},
|
||||||
@ -61,15 +63,15 @@ fun ConfirmDeletionDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
val label = stringResource(R.string.dialog_close_cancel_label)
|
val label = stringResource(R.string.dialog_close_cancel_label)
|
||||||
Button(
|
TextButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||||
onClick = {
|
onClick = {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Cancel,
|
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.Animatable
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.gestures.transformable
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
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.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.myzel394.alibi.services.RecorderService
|
|
||||||
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.utils.clamp
|
import app.myzel394.alibi.ui.utils.clamp
|
||||||
@ -35,10 +37,11 @@ private const val GROW_END = BOX_DIFF * 4
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RealtimeAudioVisualizer(
|
fun RealtimeAudioVisualizer(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
audioRecorder: AudioRecorderModel,
|
audioRecorder: AudioRecorderModel,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val amplitudes = audioRecorder.amplitudes!!
|
val amplitudes = audioRecorder.amplitudes
|
||||||
val primary = MaterialTheme.colorScheme.primary
|
val primary = MaterialTheme.colorScheme.primary
|
||||||
val primaryMuted = primary.copy(alpha = 0.3f)
|
val primaryMuted = primary.copy(alpha = 0.3f)
|
||||||
|
|
||||||
@ -63,17 +66,28 @@ fun RealtimeAudioVisualizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidth = with (LocalDensity.current) {configuration.screenWidthDp.dp.toPx()}
|
// Use greater value of width and height to make sure the amplitudes are shown
|
||||||
|
// when the user rotates the device
|
||||||
LaunchedEffect(screenWidth) {
|
val availableSpace = with(LocalDensity.current) {
|
||||||
// Add 1 to allow the visualizer to overflow the screen
|
Math.max(
|
||||||
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
|
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(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = modifier.transformable(transformState),
|
||||||
.fillMaxWidth()
|
|
||||||
.height(300.dp),
|
|
||||||
) {
|
) {
|
||||||
val height = this.size.height / 2f
|
val height = this.size.height / 2f
|
||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
@ -86,9 +100,11 @@ fun RealtimeAudioVisualizer(
|
|||||||
val isOverThreshold = offset >= GROW_START_INDEX
|
val isOverThreshold = offset >= GROW_START_INDEX
|
||||||
val horizontalProgress = (
|
val horizontalProgress = (
|
||||||
clamp(horizontalValue, GROW_START, GROW_END)
|
clamp(horizontalValue, GROW_START, GROW_END)
|
||||||
- GROW_START) / (GROW_END - GROW_START)
|
- GROW_START) / (GROW_END - GROW_START)
|
||||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
val amplitudePercentage =
|
||||||
val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
|
||||||
|
val boxHeight =
|
||||||
|
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
||||||
|
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BitrateTile() {
|
fun AudioRecorderBitrateTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
fun updateValue(bitRate: Int) {
|
fun updateValue(bitRate: Int) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -81,11 +77,11 @@ fun BitrateTile() {
|
|||||||
val bitRate = text?.toIntOrNull()
|
val bitRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (bitRate == null) {
|
if (bitRate == null) {
|
||||||
ValidationResult.Invalid(notNumberLabel)
|
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitRate !in 1..320) {
|
if (bitRate !in 1..320) {
|
||||||
ValidationResult.Invalid(notInRangeLabel)
|
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -94,7 +90,9 @@ fun BitrateTile() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { 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)
|
updateValue(bitRate * 1000)
|
||||||
}
|
}
|
||||||
@ -128,7 +126,7 @@ fun BitrateTile() {
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {bitRate ->
|
) { bitRate ->
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.format_kbps,
|
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 android.media.MediaRecorder
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AudioFile
|
|
||||||
import androidx.compose.material.icons.filled.Memory
|
import androidx.compose.material.icons.filled.Memory
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -13,7 +12,6 @@ import androidx.compose.material3.SnackbarDuration
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -34,18 +32,16 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EncoderTile(
|
fun AudioRecorderEncoderTile(
|
||||||
snackbarHostState: SnackbarHostState
|
snackbarHostState: SnackbarHostState,
|
||||||
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
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?) {
|
fun updateValue(encoder: Int?) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -92,7 +88,7 @@ fun EncoderTile(
|
|||||||
selected = settings.audioRecorderSettings.encoder == index,
|
selected = settings.audioRecorderSettings.encoder == index,
|
||||||
)
|
)
|
||||||
}.toList()
|
}.toList()
|
||||||
) {index, option ->
|
) { index, _ ->
|
||||||
updateValue(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.Icons
|
||||||
import androidx.compose.material.icons.filled.AudioFile
|
import androidx.compose.material.icons.filled.AudioFile
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -8,13 +7,8 @@ import androidx.compose.material3.ButtonDefaults
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -35,14 +29,12 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OutputFormatTile() {
|
fun AudioRecorderOutputFormatTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
|
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
|
||||||
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
|
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
|
||||||
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
|
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
|
||||||
@ -74,7 +66,7 @@ fun OutputFormatTile() {
|
|||||||
selected = settings.audioRecorderSettings.outputFormat == option,
|
selected = settings.audioRecorderSettings.outputFormat == option,
|
||||||
)
|
)
|
||||||
}.toList()
|
}.toList()
|
||||||
) {index, option ->
|
) { index, _ ->
|
||||||
updateValue(availableOptions[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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||||
import androidx.compose.material.icons.filled.Tune
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SamplingRateTile() {
|
fun AudioRecorderSamplingRateTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
fun updateValue(samplingRate: Int?) {
|
fun updateValue(samplingRate: Int?) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -62,7 +58,8 @@ fun SamplingRateTile() {
|
|||||||
header = Header.Default(
|
header = Header.Default(
|
||||||
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
||||||
icon = IconSource(
|
icon = IconSource(
|
||||||
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
|
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
|
||||||
|
.asPainterResource(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -81,11 +78,11 @@ fun SamplingRateTile() {
|
|||||||
val samplingRate = text?.toIntOrNull()
|
val samplingRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (samplingRate == null) {
|
if (samplingRate == null) {
|
||||||
ValidationResult.Invalid(notNumberLabel)
|
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (samplingRate!! <= 1000) {
|
if (samplingRate <= 1000) {
|
||||||
ValidationResult.Invalid(mustBeGreaterThanLabel)
|
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -94,7 +91,8 @@ fun SamplingRateTile() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { 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)
|
updateValue(samplingRate)
|
||||||
}
|
}
|
||||||
@ -117,7 +115,8 @@ fun SamplingRateTile() {
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
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(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {samplingRate ->
|
) { samplingRate ->
|
||||||
Text(
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -13,25 +12,21 @@ import app.myzel394.alibi.R
|
|||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ForceExactMaxDurationTile() {
|
fun AudioRecorderShowAllMicrophonesTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
fun updateValue(forceExactMaxDuration: Boolean) {
|
fun updateValue(showAllMicrophones: Boolean) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setAudioRecorderSettings(
|
||||||
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
|
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,17 +34,17 @@ fun ForceExactMaxDurationTile() {
|
|||||||
|
|
||||||
|
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
|
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
|
||||||
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
|
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
|
||||||
leading = {
|
leading = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.GraphicEq,
|
Icons.Default.MicExternalOn,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = settings.audioRecorderSettings.forceExactMaxDuration,
|
checked = settings.audioRecorderSettings.showAllMicrophones,
|
||||||
onCheckedChange = ::updateValue,
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Timer
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -34,21 +32,23 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun IntervalDurationTile() {
|
fun IntervalDurationTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
fun updateValue(intervalDuration: Long) {
|
fun updateValue(intervalDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (intervalDuration > settings.maxDuration) {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setMaxDuration(intervalDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setIntervalDuration(intervalDuration)
|
||||||
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,7 +67,7 @@ fun IntervalDurationTile() {
|
|||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.MM_SS,
|
timeFormat = DurationFormat.MM_SS,
|
||||||
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
|
currentTime = settings.intervalDuration / 1000,
|
||||||
minTime = 10,
|
minTime = 10,
|
||||||
maxTime = 60 * 60,
|
maxTime = 60 * 60,
|
||||||
)
|
)
|
||||||
@ -90,7 +90,7 @@ fun IntervalDurationTile() {
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
|
text = formatDuration(settings.intervalDuration),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -98,7 +98,7 @@ fun IntervalDurationTile() {
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {duration ->
|
) { duration ->
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(duration),
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Memory
|
|
||||||
import androidx.compose.material.icons.filled.Timer
|
import androidx.compose.material.icons.filled.Timer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -33,21 +31,23 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MaxDurationTile() {
|
fun MaxDurationTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val showDialog = rememberUseCaseState()
|
val showDialog = rememberUseCaseState()
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
val settings = dataStore
|
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
fun updateValue(maxDuration: Long) {
|
fun updateValue(maxDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (maxDuration < settings.intervalDuration) {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setIntervalDuration(maxDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setMaxDuration(maxDuration)
|
||||||
it.audioRecorderSettings.setMaxDuration(maxDuration)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,10 +65,10 @@ fun MaxDurationTile() {
|
|||||||
updateValue(newTimeInSeconds * 1000L)
|
updateValue(newTimeInSeconds * 1000L)
|
||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.MM_SS,
|
timeFormat = DurationFormat.HH_MM,
|
||||||
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
|
currentTime = settings.maxDuration / 1000,
|
||||||
minTime = 60,
|
minTime = 60,
|
||||||
maxTime = 24 * 60 * 60,
|
maxTime = 23 * 60 * 60 + 59 * 60,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
@ -88,14 +88,14 @@ fun MaxDurationTile() {
|
|||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
|
Text(formatDuration(settings.maxDuration))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extra = {
|
extra = {
|
||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
|
items = AudioRecorderSettings.EXAMPLE_MAX_DURATIONS,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {maxDuration ->
|
) { maxDuration ->
|
||||||
Text(formatDuration(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 androidx.core.os.LocaleListCompat
|
||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.SUPPORTED_LOCALES
|
import app.myzel394.alibi.SUPPORTED_LOCALES
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
import app.myzel394.alibi.ui.utils.IconResource
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -64,6 +64,7 @@ fun ExplanationPage(
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ChevronRight,
|
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