mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 14:55:26 +02:00
Compare commits
768 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 | ||
![]() |
155c02b1b0 | ||
![]() |
cad362a7be | ||
![]() |
7c2767188c | ||
![]() |
de777223aa | ||
![]() |
da7f91a587 | ||
![]() |
541ed41012 | ||
![]() |
7c9bf4419a | ||
![]() |
1fa0ae1598 | ||
![]() |
c3f078879f | ||
![]() |
b5272e8d1c | ||
![]() |
bd6a18e201 | ||
![]() |
1486ba9533 | ||
![]() |
8dcefc59eb | ||
![]() |
3e41030315 | ||
![]() |
fc47baf56e | ||
![]() |
191986a99f | ||
![]() |
bf405aa76d | ||
![]() |
3a98762aa9 | ||
![]() |
fead95f1fc | ||
![]() |
d87c7a182e | ||
![]() |
8bd9e3d35a | ||
![]() |
d8e137abf9 | ||
![]() |
56deb12373 | ||
![]() |
7b0230d4f2 | ||
![]() |
a10bba9b75 | ||
![]() |
99bb0d1cee | ||
![]() |
66b1d8a88d | ||
![]() |
7620a065b4 | ||
![]() |
ad1348d9a5 | ||
![]() |
a1895afafb | ||
![]() |
236a0d04e7 | ||
![]() |
6c16928896 | ||
![]() |
06be887d09 |
31
.github/actions/prepare-keystore/action.yml
vendored
Normal file
31
.github/actions/prepare-keystore/action.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Prepare KeyStore
|
||||
description: Write the KeyStore file and properties to disk
|
||||
|
||||
inputs:
|
||||
signingStorePassword:
|
||||
description: 'The password for the KeyStore'
|
||||
required: true
|
||||
signingKeyPassword:
|
||||
description: 'The password for the Key'
|
||||
required: true
|
||||
signingKeyAlias:
|
||||
description: 'The alias for the Key'
|
||||
required: true
|
||||
keyStoreBase64:
|
||||
description: 'The KeyStore file encoded as base64'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Write Keystore file 🗄️
|
||||
shell: bash
|
||||
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
|
||||
|
||||
- name: Write Keystore properties 🗝️
|
||||
shell: bash
|
||||
run: |
|
||||
echo "storeFile=/home/runner/key.jks" > key.properties
|
||||
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
||||
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
||||
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
13
.github/workflows/build-testing.yaml
vendored
13
.github/workflows/build-testing.yaml
vendored
@ -7,15 +7,15 @@ jobs:
|
||||
debug-builds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "adopt"
|
||||
java-version: 19
|
||||
java-version: 21
|
||||
cache: "gradle"
|
||||
|
||||
- name: Compile
|
||||
@ -23,6 +23,7 @@ jobs:
|
||||
./gradlew assembleDebug
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
name: alibi-app-debug-apks
|
||||
path: app/build/outputs/apk/debug/app-*-debug.apk
|
||||
|
52
.github/workflows/release-app-github.yaml
vendored
Normal file
52
.github/workflows/release-app-github.yaml
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
name: Build and publish app
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
release-app-github:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Write KeyStore 🗝️
|
||||
uses: ./.github/actions/prepare-keystore
|
||||
with:
|
||||
signingStorePassword: ${{ secrets.SIGNING_STORE_PASSWORD }}
|
||||
signingKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
|
||||
signingKeyAlias: ${{ secrets.SIGNING_KEY_ALIAS }}
|
||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: 21
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build APKs 📱
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APKs 🚀
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
files: app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Build AABs 📱
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
- name: Upload APKs bundles 🚀
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
files: app/build/outputs/bundle/release/*.aab
|
44
.github/workflows/release-app-google-play.yaml
vendored
Normal file
44
.github/workflows/release-app-google-play.yaml
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
name: Build and publish app to Google Play
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
release-app-google-play:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: Write KeyStore 🗝️
|
||||
uses: ./.github/actions/prepare-keystore
|
||||
with:
|
||||
signingStorePassword: ${{ secrets.SIGNING_STORE_PASSWORD }}
|
||||
signingKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
|
||||
signingKeyAlias: ${{ secrets.SIGNING_KEY_ALIAS }}
|
||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: 21
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build APKs 📱
|
||||
run: ./gradlew bundleRelease
|
||||
|
||||
- name: Upload APKs 🚀
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_STORE_SERVICE_ACCOUNT }}
|
||||
packageName: app.myzel394.alibi
|
||||
releaseFiles: app/build/outputs/bundle/release/app-release.aab
|
||||
track: production
|
||||
status: inProgress
|
||||
inAppUpdatePriority: 2
|
||||
userFraction: 0.2
|
44
.github/workflows/release-app.yaml
vendored
44
.github/workflows/release-app.yaml
vendored
@ -1,44 +0,0 @@
|
||||
name: Build and publish app
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
build-app:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Write Keystore file 🗄️
|
||||
id: android_keystore
|
||||
uses: timheuer/base64-to-file@v1.0.3
|
||||
with:
|
||||
fileName: key.jks
|
||||
encodedString: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Write Keystore properties 🗝️
|
||||
run: |
|
||||
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
|
||||
echo "storePassword=${{ secrets.SIGNING_STORE_PASSWORD }}" >> key.properties
|
||||
echo "keyPassword=${{ secrets.SIGNING_KEY_PASSWORD }}" >> key.properties
|
||||
echo "keyAlias=${{ secrets.SIGNING_KEY_ALIAS }}" >> key.properties
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
java-version: "17.x"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Build APKs 📱
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APKs 🚀
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
files: app/build/outputs/apk/release/*.apk
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
|
31
PRIVACY_POLICY.md
Normal file
31
PRIVACY_POLICY.md
Normal file
@ -0,0 +1,31 @@
|
||||
PRIVACY POLICY
|
||||
|
||||
1. INTRODUCTION
|
||||
|
||||
This Privacy Policy governs the use of the application ("Alibi"). Please read this Privacy Policy carefully. By using the App, you acknowledge that you have read, understood, and agree to be bound by the terms of this Privacy Policy.
|
||||
|
||||
2. DATA COLLECTION AND USE
|
||||
|
||||
Currently, the App does not collect any personal data because it does not have a network connection. However, this may change in the future. If we decide to collect data, we will update this Privacy Policy accordingly and it is your responsibility to review this Privacy Policy periodically.
|
||||
|
||||
3. LOG DATA
|
||||
|
||||
In the future, we may want to collect log data for the purpose of improving the App. This collection will be optional and you will have the choice to opt-in. The log data may include information such as your device's Internet Protocol ("IP") address, device name, operating system version, the configuration of the App, the time and date of your use of the App, and other statistics.
|
||||
|
||||
4. USER RESPONSIBILITIES
|
||||
|
||||
As a user, you are responsible for the maintenance and security of the App on your device. We are not responsible for any damages or losses related to your use or misuse of the App.
|
||||
|
||||
5. PRIVACY POLICY CHANGES
|
||||
|
||||
It is your responsibility to check this Privacy Policy periodically for changes. Your continued use of the App following the posting of any changes to this Privacy Policy constitutes acceptance of those changes.
|
||||
|
||||
6. COMMUNICATION
|
||||
|
||||
We do not have an obligation to inform users of any changes to this Privacy Policy. It is your responsibility to review this Privacy Policy periodically and stay informed about any changes to it.
|
||||
|
||||
7. LEGAL DISCLAIMER
|
||||
|
||||
We disclaim all warranties, express or implied, including any warranties of accuracy, non-infringement, merchantability, and fitness for a particular purpose. We are not liable for any damages, whether direct, indirect, special, consequential, or other damages, arising from your use of the App.
|
||||
|
||||
By using the App, you agree to the terms of this Privacy Policy. If you do not agree with these terms, please do not use the App.
|
28
README.md
28
README.md
@ -3,18 +3,21 @@
|
||||
# Alibi
|
||||
|
||||
<p float="left" align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.webp" width="30%" />
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.webp" width="30%" />
|
||||
</p>
|
||||
|
||||
Alibi keeps recording in the background and saves the last 30 minutes at your request.
|
||||
Alibi keeps recording audio/video in the background and saves the last 30 minutes at your request.
|
||||
Everything is completely configurable. No internet connection required.
|
||||
|
||||
# Download
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/app.myzel394.alibi)
|
||||
[<img src="readme_content/google-play-badge.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=app.myzel394.alibi)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/app.myzel394.alibi)
|
||||
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
||||
|
||||
# Supporting Alibi
|
||||
@ -27,16 +30,13 @@ Add a new feature or fix bugs.
|
||||
|
||||
## Add translations
|
||||
|
||||
Translate Alibi into your language so that other people can use it more easily.
|
||||
[Translate Alibi into your language using Crowdin](https://crowdin.com/project/alibi), so that other
|
||||
people can use it more easily.
|
||||
|
||||
## Donate
|
||||
|
||||
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
|
||||
it's a really small amount and if everyone did that, I can totally focus on Alibi and my other open
|
||||
It might sound crazy, but if you would just donate $ 1, it would totally mean the world to me, since
|
||||
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
|
||||
source projects. :)
|
||||
|
||||
You can donate via:
|
||||
|
||||
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
|
||||
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
|
||||
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
|
||||
You can donate via [GitHub Sponsors](https://github.com/sponsors/Myzel394) or via [crypto currencies](https://github.com/Myzel394/contact-me?tab=readme-ov-file#donations).
|
||||
|
@ -14,6 +14,14 @@ android {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
lint {
|
||||
disable "ExtraTranslation"
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
@ -27,8 +35,8 @@ android {
|
||||
applicationId "app.myzel394.alibi"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 3
|
||||
versionName "0.2.0"
|
||||
versionCode 16
|
||||
versionName "0.5.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -70,9 +78,11 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.1'
|
||||
kotlinCompilerExtensionVersion '1.5.10'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@ -82,40 +92,60 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||
implementation 'androidx.activity:activity-compose:1.9.1'
|
||||
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||
implementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-graphics'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.3"
|
||||
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
|
||||
implementation "androidx.navigation:navigation-compose:2.7.0-rc01"
|
||||
implementation "androidx.navigation:navigation-compose:2.7.7"
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.46.1'
|
||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
|
||||
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
|
||||
implementation 'com.google.dagger:hilt-android:2.49'
|
||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
|
||||
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
|
||||
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:5.1'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
||||
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1'
|
||||
|
||||
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
||||
|
||||
def camerax_version = "1.3.4"
|
||||
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||
implementation "androidx.camera:camera-video:${camerax_version}"
|
||||
|
||||
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||
implementation "androidx.camera:camera-extensions:${camerax_version}"
|
||||
|
||||
|
||||
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
|
||||
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
}
|
@ -2,10 +2,39 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Required for Bluetooth microphones -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
|
||||
<!-- Starting with Android 29, apps don't need to request the READ_EXTERNAL_STORAGE permission
|
||||
for files in their own MediaStore -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".UpdateSettingsApp"
|
||||
@ -17,6 +46,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Alibi"
|
||||
android:hardwareAccelerated="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@ -37,7 +67,23 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone|camera" />
|
||||
|
||||
<service
|
||||
android:name=".services.AudioRecorderService"
|
||||
android:foregroundServiceType="microphone" />
|
||||
<service
|
||||
android:name=".services.VideoRecorderService"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
|
||||
<!-- Change locale for Android <= 12 -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
3
app/src/main/java/app/myzel394/alibi/Constants.kt
Normal file
3
app/src/main/java/app/myzel394/alibi/Constants.kt
Normal file
@ -0,0 +1,3 @@
|
||||
package app.myzel394.alibi
|
||||
|
||||
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE", "tr-TR")
|
@ -2,10 +2,18 @@ package app.myzel394.alibi
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.datastore.dataStore
|
||||
import app.myzel394.alibi.db.AppSettingsSerializer
|
||||
import app.myzel394.alibi.ui.AsLockedApp
|
||||
import app.myzel394.alibi.ui.LockedAppHandlers
|
||||
import app.myzel394.alibi.ui.Navigation
|
||||
import app.myzel394.alibi.ui.theme.AlibiTheme
|
||||
|
||||
@ -15,12 +23,27 @@ val Context.dataStore by dataStore(
|
||||
serializer = AppSettingsSerializer()
|
||||
)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
AlibiTheme {
|
||||
Navigation()
|
||||
LockedAppHandlers()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background
|
||||
)
|
||||
) {
|
||||
AsLockedApp {
|
||||
Navigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,45 @@
|
||||
package app.myzel394.alibi.db
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
@Serializable
|
||||
data class AppSettings(
|
||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
|
||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
|
||||
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
|
||||
|
||||
val appLockSettings: AppLockSettings? = null,
|
||||
|
||||
val hasSeenOnboarding: Boolean = false,
|
||||
val showAdvancedSettings: Boolean = false,
|
||||
val theme: Theme = Theme.SYSTEM,
|
||||
val lastRecording: RecordingInformation? = null,
|
||||
|
||||
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
|
||||
|
||||
/// Recording information
|
||||
// 30 minutes
|
||||
val maxDuration: Long = 15 * 60 * 1000L,
|
||||
// 60 seconds
|
||||
val intervalDuration: Long = 60 * 1000L,
|
||||
|
||||
val notificationSettings: NotificationSettings? = null,
|
||||
val deleteRecordingsImmediately: Boolean = false,
|
||||
val saveFolder: String? = null,
|
||||
) {
|
||||
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
||||
return copy(showAdvancedSettings = showAdvancedSettings)
|
||||
@ -25,149 +49,43 @@ data class AppSettings(
|
||||
return copy(audioRecorderSettings = audioRecorderSettings)
|
||||
}
|
||||
|
||||
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
|
||||
return copy(videoRecorderSettings = videoRecorderSettings)
|
||||
}
|
||||
|
||||
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
|
||||
return copy(notificationSettings = notificationSettings)
|
||||
}
|
||||
|
||||
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
|
||||
return copy(hasSeenOnboarding = hasSeenOnboarding)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance(): AppSettings = AppSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LastRecording(
|
||||
val folderPath: String,
|
||||
@Serializable(with = LocalDateTimeSerializer::class)
|
||||
val recordingStart: LocalDateTime,
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val fileExtension: String,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
) {
|
||||
val fileFolder: File
|
||||
get() = File(folderPath)
|
||||
|
||||
val filePaths: List<File>
|
||||
get() =
|
||||
File(folderPath).listFiles()?.filter {
|
||||
val name = it.nameWithoutExtension
|
||||
|
||||
name.toIntOrNull() != null
|
||||
}?.toList() ?: emptyList()
|
||||
|
||||
val hasRecordingAvailable: Boolean
|
||||
get() = filePaths.isNotEmpty()
|
||||
|
||||
private fun stripConcatenatedFileToExactDuration(
|
||||
outputFile: File
|
||||
) {
|
||||
// Move the concatenated file to a temporary file
|
||||
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
|
||||
outputFile.renameTo(rawFile)
|
||||
|
||||
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Failed to strip concatenated audio")
|
||||
}
|
||||
fun setTheme(theme: Theme): AppSettings {
|
||||
return copy(theme = theme)
|
||||
}
|
||||
|
||||
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||
val paths = filePaths.joinToString("|")
|
||||
val fileName = recordingStart
|
||||
.format(ISO_DATE_TIME)
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "_")
|
||||
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
|
||||
|
||||
if (outputFile.exists() && !forceConcatenation) {
|
||||
return outputFile
|
||||
}
|
||||
|
||||
val command = "-i 'concat:$paths' -y" +
|
||||
" -acodec copy" +
|
||||
" -metadata title='$fileName' " +
|
||||
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
|
||||
" -metadata batch_count='${filePaths.size}'" +
|
||||
" -metadata batch_duration='${intervalDuration}'" +
|
||||
" -metadata max_duration='${maxDuration}'" +
|
||||
" $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
|
||||
if (!ReturnCode.isSuccess(session.returnCode)) {
|
||||
Log.d(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.getState(),
|
||||
session.getReturnCode(),
|
||||
session.getFailStackTrace()
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Failed to concatenate audios")
|
||||
}
|
||||
|
||||
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
|
||||
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
||||
stripConcatenatedFileToExactDuration(outputFile)
|
||||
}
|
||||
|
||||
return outputFile
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AudioRecorderSettings(
|
||||
val maxDuration: Long = 30 * 60 * 1000L,
|
||||
// 60 seconds
|
||||
val intervalDuration: Long = 60 * 1000L,
|
||||
val forceExactMaxDuration: Boolean = true,
|
||||
// 320 Kbps
|
||||
val bitRate: Int = 320000,
|
||||
val samplingRate: Int? = null,
|
||||
val outputFormat: Int? = null,
|
||||
val encoder: Int? = null,
|
||||
) {
|
||||
fun getOutputFormat(): Int = outputFormat ?:
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
else
|
||||
MediaRecorder.OutputFormat.THREE_GPP
|
||||
|
||||
fun getSamplingRate(): Int = samplingRate ?: when(getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> 48000
|
||||
MediaRecorder.OutputFormat.WEBM -> 48000
|
||||
MediaRecorder.OutputFormat.AMR_NB -> 8000
|
||||
MediaRecorder.OutputFormat.AMR_WB -> 16000
|
||||
MediaRecorder.OutputFormat.OGG -> 48000
|
||||
else -> 48000
|
||||
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
|
||||
return copy(lastRecording = lastRecording)
|
||||
}
|
||||
|
||||
fun getEncoder(): Int = encoder ?:
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.AudioEncoder.AAC
|
||||
else
|
||||
MediaRecorder.AudioEncoder.AMR_NB
|
||||
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
|
||||
return copy(filenameFormat = filenameFormat)
|
||||
}
|
||||
|
||||
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
|
||||
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")
|
||||
}
|
||||
@ -179,8 +97,186 @@ data class AudioRecorderSettings(
|
||||
return copy(intervalDuration = duration)
|
||||
}
|
||||
|
||||
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
|
||||
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
|
||||
}
|
||||
|
||||
fun setSaveFolder(saveFolder: String?): AppSettings {
|
||||
return copy(saveFolder = saveFolder)
|
||||
}
|
||||
|
||||
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
|
||||
return copy(appLockSettings = appLockSettings)
|
||||
}
|
||||
|
||||
fun saveLastRecording(recorder: RecorderModel): AppSettings {
|
||||
return if (deleteRecordingsImmediately) {
|
||||
this
|
||||
} else {
|
||||
setLastRecording(
|
||||
recorder.recorderService!!.getRecordingInformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If the object is present, biometric authentication is enabled.
|
||||
// To disable biometric authentication, set the instance to null.
|
||||
fun isAppLockEnabled() = appLockSettings != null
|
||||
|
||||
fun requiresExternalStoragePermission(context: Context): Boolean {
|
||||
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
))
|
||||
}
|
||||
|
||||
fun exportToString(): String {
|
||||
return Json.encodeToString(serializer(), this)
|
||||
}
|
||||
|
||||
enum class Theme {
|
||||
SYSTEM,
|
||||
LIGHT,
|
||||
DARK,
|
||||
}
|
||||
|
||||
enum class FilenameFormat {
|
||||
DATETIME_ABSOLUTE_START,
|
||||
DATETIME_RELATIVE_START,
|
||||
DATETIME_NOW,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance(): AppSettings = AppSettings()
|
||||
|
||||
fun fromExportedString(data: String): AppSettings {
|
||||
return Json.decodeFromString(
|
||||
serializer(),
|
||||
data,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RecordingInformation(
|
||||
val folderPath: String,
|
||||
@Serializable(with = LocalDateTimeSerializer::class)
|
||||
val recordingStart: LocalDateTime,
|
||||
val batchesAmount: Int,
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val fileExtension: String,
|
||||
val type: Type,
|
||||
) {
|
||||
fun hasRecordingsAvailable(context: Context): Boolean =
|
||||
when (type) {
|
||||
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
|
||||
.hasRecordingsAvailable()
|
||||
|
||||
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
|
||||
.hasRecordingsAvailable()
|
||||
}
|
||||
|
||||
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
|
||||
return when (filenameFormat) {
|
||||
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
|
||||
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
|
||||
getFullDuration() / 1000
|
||||
)
|
||||
|
||||
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFullDuration(): Long {
|
||||
// This is not accurate, since the last batch may be shorter than the others
|
||||
// but it's good enough
|
||||
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
AUDIO,
|
||||
VIDEO,
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AudioRecorderSettings(
|
||||
// 320 Kbps
|
||||
val bitRate: Int = 320000,
|
||||
val samplingRate: Int? = null,
|
||||
val outputFormat: Int? = null,
|
||||
val encoder: Int? = null,
|
||||
val showAllMicrophones: Boolean = false,
|
||||
) {
|
||||
fun getOutputFormat(): Int {
|
||||
if (outputFormat != null) {
|
||||
return outputFormat
|
||||
}
|
||||
|
||||
if (encoder == null) {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
else MediaRecorder.OutputFormat.THREE_GPP
|
||||
}
|
||||
|
||||
return when (encoder) {
|
||||
MediaRecorder.AudioEncoder.AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||
MediaRecorder.AudioEncoder.AAC_ELD -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||
MediaRecorder.AudioEncoder.AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
||||
MediaRecorder.AudioEncoder.AMR_WB -> MediaRecorder.OutputFormat.AMR_WB
|
||||
MediaRecorder.AudioEncoder.HE_AAC -> MediaRecorder.OutputFormat.AAC_ADTS
|
||||
MediaRecorder.AudioEncoder.VORBIS -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaRecorder.OutputFormat.OGG
|
||||
} else {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
}
|
||||
}
|
||||
|
||||
MediaRecorder.AudioEncoder.OPUS -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaRecorder.OutputFormat.OGG
|
||||
} else {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
}
|
||||
}
|
||||
|
||||
else -> MediaRecorder.OutputFormat.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
fun getMimeType(): String = when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "audio/aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "audio/3gpp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "audio/mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "audio/ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "audio/webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "audio/amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "audio/amr-wb"
|
||||
MediaRecorder.OutputFormat.OGG -> "audio/ogg"
|
||||
else -> "audio/3gpp"
|
||||
}
|
||||
|
||||
fun getSamplingRate(): Int = samplingRate ?: when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> 44100
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> 48000
|
||||
MediaRecorder.OutputFormat.WEBM -> 48000
|
||||
MediaRecorder.OutputFormat.AMR_NB -> 8000
|
||||
MediaRecorder.OutputFormat.AMR_WB -> 16000
|
||||
MediaRecorder.OutputFormat.OGG -> 48000
|
||||
else -> 48000
|
||||
}
|
||||
|
||||
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.AudioEncoder.AAC
|
||||
else
|
||||
MediaRecorder.AudioEncoder.AMR_NB
|
||||
|
||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||
println("bitRate: $bitRate")
|
||||
if (bitRate !in 1000..320000) {
|
||||
throw Exception("Bit rate must be between 1000 and 320000")
|
||||
}
|
||||
@ -212,33 +308,45 @@ data class AudioRecorderSettings(
|
||||
return copy(encoder = encoder)
|
||||
}
|
||||
|
||||
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
||||
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
|
||||
throw Exception("Max duration must be between 1 minute and 1 hour")
|
||||
}
|
||||
|
||||
if (duration < intervalDuration) {
|
||||
throw Exception("Max duration must be greater than interval duration")
|
||||
}
|
||||
|
||||
return copy(maxDuration = duration)
|
||||
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||
return copy(showAllMicrophones = showAllMicrophones)
|
||||
}
|
||||
|
||||
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
|
||||
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
||||
return true
|
||||
}
|
||||
|
||||
val supportedFormats = ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[encoder]!!
|
||||
|
||||
return supportedFormats.contains(outputFormat)
|
||||
}
|
||||
|
||||
val fileExtension: String
|
||||
get() = when (getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
||||
val EXAMPLE_MAX_DURATIONS = listOf(
|
||||
1 * 60 * 1000L,
|
||||
5 * 60 * 1000L,
|
||||
15 * 60 * 1000L,
|
||||
30 * 60 * 1000L,
|
||||
60 * 60 * 1000L,
|
||||
2 * 60 * 60 * 1000L,
|
||||
3 * 60 * 60 * 1000L,
|
||||
)
|
||||
val EXAMPLE_DURATION_TIMES = listOf(
|
||||
60 * 1000L,
|
||||
60 * 2 * 1000L,
|
||||
60 * 5 * 1000L,
|
||||
60 * 10 * 1000L,
|
||||
60 * 15 * 1000L,
|
||||
@ -284,5 +392,226 @@ data class AudioRecorderSettings(
|
||||
6 to "VORBIS",
|
||||
7 to "OPUS",
|
||||
)
|
||||
val ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP: Map<Int, Array<Int>> = mutableMapOf(
|
||||
MediaRecorder.AudioEncoder.DEFAULT to arrayOf(
|
||||
MediaRecorder.OutputFormat.DEFAULT,
|
||||
),
|
||||
MediaRecorder.AudioEncoder.AAC to arrayOf(
|
||||
MediaRecorder.OutputFormat.THREE_GPP,
|
||||
MediaRecorder.OutputFormat.MPEG_4,
|
||||
MediaRecorder.OutputFormat.AAC_ADTS,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) MediaRecorder.OutputFormat.MPEG_2_TS else null,
|
||||
).filterNotNull().toTypedArray(),
|
||||
MediaRecorder.AudioEncoder.AAC_ELD to arrayOf(
|
||||
MediaRecorder.OutputFormat.THREE_GPP,
|
||||
MediaRecorder.OutputFormat.MPEG_4,
|
||||
MediaRecorder.OutputFormat.AAC_ADTS,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) MediaRecorder.OutputFormat.MPEG_2_TS else null,
|
||||
).filterNotNull().toTypedArray(),
|
||||
MediaRecorder.AudioEncoder.AMR_NB to arrayOf(
|
||||
MediaRecorder.OutputFormat.THREE_GPP,
|
||||
MediaRecorder.OutputFormat.AMR_NB,
|
||||
),
|
||||
MediaRecorder.AudioEncoder.AMR_WB to arrayOf(
|
||||
MediaRecorder.OutputFormat.THREE_GPP,
|
||||
MediaRecorder.OutputFormat.AMR_WB,
|
||||
),
|
||||
MediaRecorder.AudioEncoder.HE_AAC to arrayOf(
|
||||
MediaRecorder.OutputFormat.THREE_GPP,
|
||||
MediaRecorder.OutputFormat.MPEG_4,
|
||||
MediaRecorder.OutputFormat.AAC_ADTS,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) MediaRecorder.OutputFormat.MPEG_2_TS else null,
|
||||
).filterNotNull().toTypedArray(),
|
||||
MediaRecorder.AudioEncoder.VORBIS to arrayOf(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaRecorder.OutputFormat.OGG else null,
|
||||
MediaRecorder.OutputFormat.MPEG_4
|
||||
).filterNotNull().toTypedArray(),
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaRecorder.AudioEncoder.OPUS, arrayOf(MediaRecorder.OutputFormat.OGG))
|
||||
}
|
||||
}.toMap()
|
||||
val OUTPUT_FORMATS_SUPPORTED_ENCODER_MAP = (mutableMapOf<Int, Array<Int>>().also { map ->
|
||||
ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP.forEach { (encoder, formats) ->
|
||||
formats.forEach { format ->
|
||||
if (map.containsKey(format)) {
|
||||
map[format]!!.plus(encoder)
|
||||
} else {
|
||||
map[format] = arrayOf(encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}).toMap()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class VideoRecorderSettings(
|
||||
val targetedVideoBitRate: Int? = null,
|
||||
val quality: String? = null,
|
||||
val targetFrameRate: Int? = null,
|
||||
) {
|
||||
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
|
||||
return copy(targetedVideoBitRate = bitRate)
|
||||
}
|
||||
|
||||
fun setQuality(quality: Quality?): VideoRecorderSettings {
|
||||
val invertedMap = QUALITY_NAME_QUALITY_MAP.entries.associateBy({ it.value }, { it.key })
|
||||
|
||||
return copy(quality = quality?.let { invertedMap[it] })
|
||||
}
|
||||
|
||||
fun setTargetFrameRate(frameRate: Int?): VideoRecorderSettings {
|
||||
return copy(targetFrameRate = frameRate)
|
||||
}
|
||||
|
||||
fun getQuality(): Quality? =
|
||||
quality?.let {
|
||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
||||
}
|
||||
|
||||
fun getQualitySelector(): QualitySelector? =
|
||||
quality?.let {
|
||||
QualitySelector.from(
|
||||
QUALITY_NAME_QUALITY_MAP[it]!!
|
||||
)
|
||||
}
|
||||
|
||||
fun getMimeType() = "video/$fileExtension"
|
||||
|
||||
val fileExtension
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "mp4" else "3gp"
|
||||
|
||||
companion object {
|
||||
fun getDefaultInstance() = VideoRecorderSettings()
|
||||
|
||||
val QUALITY_NAME_QUALITY_MAP: Map<String, Quality> = mapOf(
|
||||
"LOWEST" to Quality.LOWEST,
|
||||
"HIGHEST" to Quality.HIGHEST,
|
||||
"SD" to Quality.SD,
|
||||
"HD" to Quality.HD,
|
||||
"FHD" to Quality.FHD,
|
||||
"UHD" to Quality.UHD,
|
||||
)
|
||||
|
||||
val EXAMPLE_BITRATE_VALUES = listOf(
|
||||
null,
|
||||
500 * 1000,
|
||||
// 1 Mbps
|
||||
1 * 1000 * 1000,
|
||||
2 * 1000 * 1000,
|
||||
4 * 1000 * 1000,
|
||||
8 * 1000 * 1000,
|
||||
16 * 1000 * 1000,
|
||||
32 * 1000 * 1000,
|
||||
50 * 1000 * 1000,
|
||||
100 * 1000 * 1000,
|
||||
)
|
||||
|
||||
val EXAMPLE_FRAME_RATE_VALUES = listOf(
|
||||
null,
|
||||
24,
|
||||
30,
|
||||
60,
|
||||
120,
|
||||
240,
|
||||
)
|
||||
|
||||
val AVAILABLE_QUALITIES = listOf(
|
||||
Quality.HIGHEST,
|
||||
Quality.UHD,
|
||||
Quality.FHD,
|
||||
Quality.HD,
|
||||
Quality.SD,
|
||||
Quality.LOWEST,
|
||||
)
|
||||
|
||||
val EXAMPLE_QUALITY_VALUES = listOf(
|
||||
null,
|
||||
) + AVAILABLE_QUALITIES
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationSettings(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val iconID: Int,
|
||||
val showOngoing: Boolean,
|
||||
val preset: Preset? = null,
|
||||
) {
|
||||
@Serializable
|
||||
sealed class Preset(
|
||||
val titleID: Int,
|
||||
val messageID: Int,
|
||||
val showOngoing: Boolean,
|
||||
val iconID: Int,
|
||||
) {
|
||||
@Serializable
|
||||
data object Default : Preset(
|
||||
R.string.ui_audioRecorder_state_recording_title,
|
||||
R.string.ui_recorder_state_recording_description,
|
||||
true,
|
||||
R.drawable.launcher_monochrome_noopacity,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Weather : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_weather_title,
|
||||
R.string.ui_recorder_state_recording_fake_weather_description,
|
||||
false,
|
||||
R.drawable.ic_cloud
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Player : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_player_title,
|
||||
R.string.ui_recorder_state_recording_fake_player_description,
|
||||
true,
|
||||
R.drawable.ic_note,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object Browser : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_browser_title,
|
||||
R.string.ui_recorder_state_recording_fake_browser_description,
|
||||
true,
|
||||
R.drawable.ic_download,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data object VPN : Preset(
|
||||
R.string.ui_recorder_state_recording_fake_vpn_title,
|
||||
R.string.ui_recorder_state_recording_fake_vpn_description,
|
||||
false,
|
||||
R.drawable.ic_vpn,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromPreset(preset: Preset): NotificationSettings {
|
||||
return NotificationSettings(
|
||||
title = "",
|
||||
message = "",
|
||||
showOngoing = preset.showOngoing,
|
||||
iconID = preset.iconID,
|
||||
preset = preset,
|
||||
)
|
||||
}
|
||||
|
||||
val PRESETS = listOf(
|
||||
Preset.Default,
|
||||
Preset.Weather,
|
||||
Preset.Player,
|
||||
Preset.Browser,
|
||||
Preset.VPN,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class AppLockSettings {
|
||||
companion object {
|
||||
fun getDefaultInstance() = AppLockSettings()
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AppSettingsSerializer: Serializer<AppSettings> {
|
||||
class AppSettingsSerializer : Serializer<AppSettings> {
|
||||
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): AppSettings {
|
||||
@ -39,8 +39,9 @@ class AppSettingsSerializer: Serializer<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||
return LocalDateTime.parse(decoder.decodeString())
|
||||
|
@ -1,7 +1,10 @@
|
||||
package app.myzel394.alibi.enums
|
||||
|
||||
enum class RecorderState {
|
||||
IDLE,
|
||||
STOPPED,
|
||||
RECORDING,
|
||||
PAUSED,
|
||||
|
||||
// Only used by the model to indicate that the service is not running
|
||||
IDLE
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class AppLockHelper {
|
||||
enum class SupportType {
|
||||
AVAILABLE,
|
||||
UNAVAILABLE,
|
||||
NONE_ENROLLED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSupportType(context: Context): SupportType {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> SupportType.AVAILABLE
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> SupportType.NONE_ENROLLED
|
||||
|
||||
else -> SupportType.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
context: Context,
|
||||
title: String,
|
||||
subtitle: String
|
||||
): CompletableDeferred<Boolean> {
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
context as FragmentActivity,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
deferred.complete(false)
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
deferred.complete(true)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
deferred.complete(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
|
||||
return deferred
|
||||
}
|
||||
|
||||
fun closeApp(context: Context) {
|
||||
(context as? Activity)?.let {
|
||||
it.finishAndRemoveTask()
|
||||
it.finishAffinity()
|
||||
it.finish()
|
||||
}
|
||||
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,176 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles
|
||||
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AudioBatchesFolder(
|
||||
override val context: Context,
|
||||
override val type: BatchType,
|
||||
override val customFolder: DocumentFile? = null,
|
||||
override val subfolderName: String = AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||
) : BatchesFolder(
|
||||
context,
|
||||
type,
|
||||
customFolder,
|
||||
subfolderName,
|
||||
) {
|
||||
override val concatenationFunction = ::concatenateAudioFiles
|
||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
||||
override val scopedMediaContentUri: Uri = SCOPED_MEDIA_CONTENT_URI
|
||||
override val legacyMediaFolder = LEGACY_MEDIA_FOLDER
|
||||
|
||||
private var customFileFileDescriptor: ParcelFileDescriptor? = null
|
||||
private var mediaFileFileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
override fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
||||
"audio/${extension}",
|
||||
fileName,
|
||||
)!!).uri
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = fileName,
|
||||
mimeType = "audio/$extension",
|
||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
||||
)
|
||||
|
||||
return FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
mediaUri
|
||||
)!!
|
||||
} else {
|
||||
val path = arrayOf(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_SUBFOLDER_NAME,
|
||||
fileName,
|
||||
).joinToString("/")
|
||||
return File(path)
|
||||
.apply {
|
||||
createNewFile()
|
||||
}.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
runCatching {
|
||||
customFileFileDescriptor?.close()
|
||||
}
|
||||
runCatching {
|
||||
mediaFileFileDescriptor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun asCustomGetFileDescriptor(
|
||||
counter: Long,
|
||||
fileExtension: String,
|
||||
): FileDescriptor {
|
||||
runCatching {
|
||||
customFileFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val file =
|
||||
getCustomDefinedFolder().createFile("audio/$fileExtension", "$counter.$fileExtension")!!
|
||||
|
||||
customFileFileDescriptor = context.contentResolver.openFileDescriptor(file.uri, "w")!!
|
||||
|
||||
return customFileFileDescriptor!!.fileDescriptor
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun asMediaGetScopedStorageFileDescriptor(
|
||||
name: String,
|
||||
mimeType: String
|
||||
): FileDescriptor {
|
||||
runCatching {
|
||||
mediaFileFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = name,
|
||||
mimeType = mimeType,
|
||||
relativePath = SCOPED_STORAGE_RELATIVE_PATH,
|
||||
)
|
||||
|
||||
mediaFileFileDescriptor = context.contentResolver.openFileDescriptor(mediaUri, "w")!!
|
||||
|
||||
return mediaFileFileDescriptor!!.fileDescriptor
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun viaInternalFolder(context: Context) = AudioBatchesFolder(context, BatchType.INTERNAL)
|
||||
|
||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
||||
AudioBatchesFolder(context, BatchType.CUSTOM, folder)
|
||||
|
||||
fun viaMediaFolder(context: Context) = AudioBatchesFolder(context, BatchType.MEDIA)
|
||||
|
||||
fun importFromFolder(folder: String, context: Context) = when (folder) {
|
||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
||||
else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!)
|
||||
}
|
||||
|
||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_PODCASTS
|
||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.audio_recordings"
|
||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH =
|
||||
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
Environment.DIRECTORY_RECORDINGS
|
||||
else
|
||||
Environment.DIRECTORY_PODCASTS)
|
||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
||||
|
||||
// Don't use those values directly, use the constants from the instance.
|
||||
// Those values are only used inside the `SaveFolderTile`
|
||||
val SCOPED_MEDIA_CONTENT_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
val LEGACY_MEDIA_FOLDER = File(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
|
||||
// Parameters to be passed in descending order
|
||||
// Those parameters first try to concatenate without re-encoding
|
||||
// if that fails, it'll try several fallback methods
|
||||
// this is audio only
|
||||
val FFMPEG_PARAMETERS = arrayOf(
|
||||
" -c copy",
|
||||
" -acodec copy",
|
||||
" -c:a aac",
|
||||
" -c:a libmp3lame",
|
||||
" -c:a libopus",
|
||||
" -c:a libvorbis",
|
||||
)
|
||||
}
|
||||
}
|
602
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal file
602
app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt
Normal file
@ -0,0 +1,602 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video.Media
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.reflect.KFunction4
|
||||
|
||||
|
||||
abstract class BatchesFolder(
|
||||
open val context: Context,
|
||||
open val type: BatchType,
|
||||
open val customFolder: DocumentFile? = null,
|
||||
open val subfolderName: String = ".recordings",
|
||||
) {
|
||||
abstract val concatenationFunction: KFunction4<Iterable<String>, String, String, (Int) -> Unit, CompletableDeferred<Unit>>
|
||||
abstract val ffmpegParameters: Array<String>
|
||||
abstract val scopedMediaContentUri: Uri
|
||||
abstract val legacyMediaFolder: File
|
||||
|
||||
val mediaPrefix
|
||||
get() = MEDIA_RECORDINGS_PREFIX + subfolderName.substring(1) + "-"
|
||||
|
||||
fun initFolders() {
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().mkdirs()
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
if (customFolder!!.findFile(subfolderName) == null) {
|
||||
customFolder!!.createDirectory(subfolderName)
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
// Scoped storage works fine on new Android versions,
|
||||
// we need to manually manage the folder on older versions
|
||||
if (!SUPPORTS_SCOPED_STORAGE) {
|
||||
legacyMediaFolder.mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInternalFolder(): File {
|
||||
return File(context.filesDir, subfolderName)
|
||||
}
|
||||
|
||||
fun getCustomDefinedFolder(): DocumentFile {
|
||||
return customFolder!!.findFile(subfolderName)!!
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
protected fun queryMediaContent(
|
||||
callback: (rawName: String, counter: Int, uri: Uri, cursor: Cursor) -> Any?,
|
||||
) {
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
null,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val rawName = cursor.getColumnIndex(Media.DISPLAY_NAME).let { id ->
|
||||
if (id == -1) null else cursor.getString(id)
|
||||
}
|
||||
|
||||
if (rawName.isNullOrBlank() || !rawName.startsWith(mediaPrefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
val counter =
|
||||
rawName.substringAfter(mediaPrefix).substringBeforeLast(".").toIntOrNull()
|
||||
?: continue
|
||||
|
||||
val id = cursor.getColumnIndex(Media._ID).let { id ->
|
||||
if (id == -1) null else cursor.getString(id)
|
||||
}
|
||||
|
||||
if (id.isNullOrBlank()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val uri = Uri.withAppendedPath(scopedMediaContentUri, id)
|
||||
|
||||
val result = callback(rawName, counter, uri, cursor)
|
||||
|
||||
if (result == false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getBatchesForFFmpeg(): List<String> {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL ->
|
||||
((getInternalFolder()
|
||||
.listFiles()
|
||||
?.filter {
|
||||
it.nameWithoutExtension.toIntOrNull() != null
|
||||
}
|
||||
?.toList()
|
||||
?: emptyList()) as List<File>)
|
||||
.sortedBy {
|
||||
it.nameWithoutExtension.toInt()
|
||||
}
|
||||
.map { it.absolutePath }
|
||||
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder()
|
||||
.listFiles()
|
||||
.filter {
|
||||
it.name?.substringBeforeLast(".")?.toIntOrNull() != null
|
||||
}
|
||||
.sortedBy {
|
||||
it.name!!.substringBeforeLast(".").toInt()
|
||||
}
|
||||
.map {
|
||||
FFmpegKitConfig.getSafParameterForRead(
|
||||
context,
|
||||
it.uri,
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
val fileUris = mutableListOf<Pair<String, Uri>>()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryMediaContent { rawName, _, uri, _ ->
|
||||
fileUris.add(Pair(rawName, uri))
|
||||
}
|
||||
} else {
|
||||
legacyMediaFolder.listFiles()?.forEach {
|
||||
fileUris.add(Pair(it.name, it.toUri()))
|
||||
}
|
||||
}
|
||||
|
||||
fileUris
|
||||
.sortedBy {
|
||||
val name = it.first
|
||||
|
||||
return@sortedBy name
|
||||
.substring(mediaPrefix.length)
|
||||
.substringBeforeLast(".")
|
||||
.toInt()
|
||||
}
|
||||
.map { pair ->
|
||||
val uri = pair.second
|
||||
|
||||
FFmpegKitConfig.getSafParameterForRead(
|
||||
context,
|
||||
uri,
|
||||
)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getName(date: LocalDateTime, extension: String): String {
|
||||
val name = date
|
||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
.toString()
|
||||
.replace(":", "-")
|
||||
.replace(".", "_")
|
||||
|
||||
return "$name.$extension"
|
||||
}
|
||||
|
||||
fun asInternalGetOutputFile(fileName: String): File {
|
||||
return File(getInternalFolder(), fileName)
|
||||
}
|
||||
|
||||
fun asMediaGetLegacyFile(name: String): File = File(
|
||||
legacyMediaFolder,
|
||||
name
|
||||
).apply {
|
||||
createNewFile()
|
||||
}
|
||||
|
||||
fun checkIfOutputAlreadyExists(
|
||||
fileName: String,
|
||||
): Boolean {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> File(getInternalFolder(), fileName).exists()
|
||||
|
||||
BatchType.CUSTOM ->
|
||||
getCustomDefinedFolder().findFile(fileName)?.exists() ?: false
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
var exists = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
queryMediaContent { rawName, _, _, _ ->
|
||||
if (rawName == fileName) {
|
||||
exists = true
|
||||
return@queryMediaContent true
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
return exists
|
||||
} else {
|
||||
return File(
|
||||
legacyMediaFolder,
|
||||
fileName,
|
||||
).exists()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String
|
||||
|
||||
abstract fun cleanup()
|
||||
|
||||
suspend fun concatenate(
|
||||
recording: RecordingInformation,
|
||||
filenameFormat: AppSettings.FilenameFormat,
|
||||
disableCache: Boolean? = null,
|
||||
onNextParameterTry: (String) -> Unit = {},
|
||||
onProgress: (Float?) -> Unit = {},
|
||||
fileName: String,
|
||||
): String {
|
||||
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
|
||||
val date = recording.getStartDateForFilename(filenameFormat)
|
||||
|
||||
if (!disableCache && checkIfOutputAlreadyExists(fileName)
|
||||
) {
|
||||
return getOutputFileForFFmpeg(
|
||||
date = recording.recordingStart,
|
||||
extension = recording.fileExtension,
|
||||
fileName = fileName,
|
||||
)
|
||||
}
|
||||
|
||||
for (parameter in ffmpegParameters) {
|
||||
Log.i("Concatenation", "Trying parameter $parameter")
|
||||
onNextParameterTry(parameter)
|
||||
onProgress(null)
|
||||
|
||||
try {
|
||||
val fullTime = recording.getFullDuration().toFloat();
|
||||
val filePaths = getBatchesForFFmpeg()
|
||||
|
||||
val outputFile = getOutputFileForFFmpeg(
|
||||
date = date,
|
||||
extension = recording.fileExtension,
|
||||
fileName = fileName,
|
||||
)
|
||||
|
||||
concatenationFunction(
|
||||
filePaths,
|
||||
outputFile,
|
||||
parameter
|
||||
) { time ->
|
||||
// The progressbar for the conversion is calculated based on the
|
||||
// current time of the conversion and the total time of the batches.
|
||||
onProgress(time / fullTime)
|
||||
}.await()
|
||||
return outputFile
|
||||
} catch (e: MediaConverter.FFmpegException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw MediaConverter.FFmpegException("Failed to concatenate")
|
||||
}
|
||||
|
||||
fun exportFolderForSettings(): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> RECORDER_INTERNAL_SELECTED_VALUE
|
||||
BatchType.MEDIA -> RECORDER_MEDIA_SELECTED_VALUE
|
||||
BatchType.CUSTOM -> customFolder!!.uri.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecordings() {
|
||||
// Currently deletes all recordings.
|
||||
// This is fine, because we are saving the recordings
|
||||
// in a dedicated subfolder
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().deleteRecursively()
|
||||
|
||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.delete()
|
||||
?: customFolder?.findFile(subfolderName)?.listFiles()?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// TODO: Also delete pending recordings
|
||||
// --> Doesn't seem to be possible :/
|
||||
context.contentResolver.delete(
|
||||
scopedMediaContentUri,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
)
|
||||
|
||||
} else {
|
||||
legacyMediaFolder.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasRecordingsAvailable(): Boolean {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.isNotEmpty() ?: false
|
||||
|
||||
BatchType.CUSTOM -> customFolder?.findFile(subfolderName)?.listFiles()?.isNotEmpty()
|
||||
?: false
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
var hasRecordings = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
arrayOf(MediaStore.MediaColumns.DISPLAY_NAME),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '$mediaPrefix%'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
hasRecordings = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasRecordings
|
||||
} else {
|
||||
return legacyMediaFolder.listFiles()?.isNotEmpty() ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecordings(range: LongRange) {
|
||||
when (type) {
|
||||
BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach {
|
||||
val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach {
|
||||
val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val deletableNames = mutableListOf<String>()
|
||||
|
||||
queryMediaContent { rawName, counter, _, _ ->
|
||||
if (counter in range) {
|
||||
deletableNames.add(rawName)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
context.contentResolver.delete(
|
||||
scopedMediaContentUri,
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} IN (${
|
||||
deletableNames.joinToString(
|
||||
","
|
||||
) { "'$it'" }
|
||||
})",
|
||||
null,
|
||||
)
|
||||
// This is unfortunate if the files can't be deleted, but let's just
|
||||
// ignore it since we can't do anything about it
|
||||
} catch (e: RuntimeException) {
|
||||
// Probably file not found
|
||||
e.printStackTrace()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Strange filename, should not happen
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
// TODO: Fix "would you like to try saving" -> Save button
|
||||
legacyMediaFolder.listFiles()?.forEach {
|
||||
val fileCounter =
|
||||
it.nameWithoutExtension.substring(mediaPrefix.length).toIntOrNull()
|
||||
?: return@forEach
|
||||
|
||||
if (fileCounter in range) {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfFolderIsAccessible(): Boolean {
|
||||
try {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> true
|
||||
BatchType.CUSTOM -> getCustomDefinedFolder().canWrite() && getCustomDefinedFolder().canRead()
|
||||
BatchType.MEDIA -> {
|
||||
if (SUPPORTS_SCOPED_STORAGE) {
|
||||
return true
|
||||
}
|
||||
|
||||
return PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) &&
|
||||
PermissionHelper.hasGranted(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error: NullPointerException) {
|
||||
error.printStackTrace()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun asInternalGetFile(counter: Long, fileExtension: String): File {
|
||||
return File(getInternalFolder(), "$counter.$fileExtension")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun getOrCreateMediaFile(
|
||||
name: String,
|
||||
mimeType: String,
|
||||
relativePath: String,
|
||||
): Uri {
|
||||
// Check if already exists
|
||||
var uri: Uri? = null
|
||||
|
||||
context.contentResolver.query(
|
||||
scopedMediaContentUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = '$name'",
|
||||
null,
|
||||
null,
|
||||
)!!.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
// No need to check for the name since the query already did that
|
||||
val id = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||
|
||||
if (id == -1) {
|
||||
return@use
|
||||
}
|
||||
|
||||
uri = ContentUris.withAppendedId(
|
||||
scopedMediaContentUri,
|
||||
cursor.getLong(id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
try {
|
||||
// Create empty output file to be able to write to it
|
||||
uri = context.contentResolver.insert(
|
||||
scopedMediaContentUri,
|
||||
ContentValues().apply {
|
||||
put(
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
name
|
||||
)
|
||||
put(
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
mimeType
|
||||
)
|
||||
|
||||
put(
|
||||
Media.RELATIVE_PATH,
|
||||
relativePath,
|
||||
)
|
||||
}
|
||||
)!!
|
||||
} catch (e: Exception) {
|
||||
Log.e("Media", "Failed to create file", e)
|
||||
}
|
||||
}
|
||||
|
||||
return uri!!
|
||||
}
|
||||
|
||||
fun getAvailableBytes(): Long? {
|
||||
if (type == BatchType.CUSTOM) {
|
||||
var fileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
try {
|
||||
fileDescriptor =
|
||||
context.contentResolver.openFileDescriptor(customFolder!!.uri, "r")!!
|
||||
val stats = Os.fstatvfs(fileDescriptor.fileDescriptor)
|
||||
|
||||
val available = stats.f_bavail * stats.f_bsize
|
||||
|
||||
runCatching {
|
||||
fileDescriptor.close()
|
||||
}
|
||||
|
||||
return available
|
||||
} catch (e: Exception) {
|
||||
runCatching {
|
||||
fileDescriptor?.close();
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
val storageManager = context.getSystemService(StorageManager::class.java) ?: return null
|
||||
val file = when (type) {
|
||||
BatchType.INTERNAL -> context.filesDir
|
||||
BatchType.MEDIA ->
|
||||
if (SUPPORTS_SCOPED_STORAGE)
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH),
|
||||
Media.EXTERNAL_CONTENT_URI.toString(),
|
||||
)
|
||||
else
|
||||
File(
|
||||
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER),
|
||||
VideoBatchesFolder.MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
BatchType.CUSTOM -> throw IllegalArgumentException("This code should not be reachable")
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
storageManager.getAllocatableBytes(storageManager.getUuidForPath(file))
|
||||
} else {
|
||||
file.usableSpace;
|
||||
}
|
||||
}
|
||||
|
||||
enum class BatchType {
|
||||
INTERNAL,
|
||||
CUSTOM,
|
||||
MEDIA,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long {
|
||||
// 350 MiB sounds like a good default
|
||||
return 350 * 1024 * 1024
|
||||
}
|
||||
|
||||
fun canAccessFolder(context: Context, uri: Uri): Boolean {
|
||||
// This always returns false for some reason, let's just assume it's true
|
||||
return true
|
||||
/*
|
||||
return try {
|
||||
// Create temp file
|
||||
val docFile = DocumentFile.fromSingleUri(context, uri)!!
|
||||
|
||||
return docFile.canWrite().also {
|
||||
println("Can write? ${it}")
|
||||
} && docFile.canRead().also {
|
||||
println("Can read? ${it}")
|
||||
}
|
||||
} catch (error: RuntimeException) {
|
||||
error.printStackTrace()
|
||||
false
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
33
app/src/main/java/app/myzel394/alibi/helpers/Doctor.kt
Normal file
33
app/src/main/java/app/myzel394/alibi/helpers/Doctor.kt
Normal file
@ -0,0 +1,33 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
|
||||
data class Doctor(
|
||||
val context: Context
|
||||
) {
|
||||
fun checkIfFileSaverDialogIsAvailable(): Boolean {
|
||||
// Since API 30, we can't query other packages so easily anymore
|
||||
// (see https://developer.android.com/training/package-visibility).
|
||||
// For now, we assume the user has a file saver app installed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return true
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
val results =
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
|
||||
|
||||
if (results.isNotEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
183
app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt
Normal file
183
app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt
Normal file
@ -0,0 +1,183 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
// Abstract class for concatenating audio and video files
|
||||
// The concatenator runs in its own thread to avoid unresponsiveness.
|
||||
// You may be wondering why we simply not iterate over the FFMPEG_PARAMETERS
|
||||
// in this thread and then call each FFmpeg initiation just right after it?
|
||||
// The answer: It's easier; We don't have to deal with the `getBatchesForFFmpeg` function, because
|
||||
// the batches are only usable once and we if iterate in this thread over the FFMPEG_PARAMETERS
|
||||
// we would need to refetch the batches here, which is more messy.
|
||||
// This is okay, because in 99% of the time the first or second parameter will work,
|
||||
// and so there is no real performance loss.
|
||||
abstract class Concatenator(
|
||||
private val inputFiles: Iterable<String>,
|
||||
private val outputFile: String,
|
||||
private val extraCommand: String
|
||||
) : Thread() {
|
||||
abstract fun concatenate(): CompletableDeferred<Unit>
|
||||
|
||||
class FFmpegException(message: String) : Exception(message)
|
||||
}
|
||||
|
||||
data class AudioConcatenator(
|
||||
private val inputFiles: Iterable<String>,
|
||||
private val outputFile: String,
|
||||
private val extraCommand: String
|
||||
) : Concatenator(
|
||||
inputFiles,
|
||||
outputFile,
|
||||
extraCommand
|
||||
) {
|
||||
override fun concatenate(): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -i 'concat:$filePathsConcatenated'" +
|
||||
" -y" +
|
||||
extraCommand +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command
|
||||
) { session ->
|
||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
Log.i(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
||||
} else {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
return completer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MediaConverter {
|
||||
companion object {
|
||||
fun concatenateAudioFiles(
|
||||
inputFiles: Iterable<String>,
|
||||
outputFile: String,
|
||||
extraCommand: String = "",
|
||||
onProgress: (Int) -> Unit = { },
|
||||
): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val filePathsConcatenated = inputFiles.joinToString("|")
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -strict normal" +
|
||||
" -i 'concat:$filePathsConcatenated'" +
|
||||
extraCommand +
|
||||
" -y" +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
{ session ->
|
||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
Log.i(
|
||||
"Audio Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(Exception("Failed to concatenate audios"))
|
||||
} else {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
},
|
||||
{},
|
||||
{ statistics ->
|
||||
onProgress(statistics.time)
|
||||
}
|
||||
)
|
||||
|
||||
return completer
|
||||
}
|
||||
|
||||
private fun createTempFile(content: String): File {
|
||||
val id = UUID.randomUUID().toString()
|
||||
|
||||
return File.createTempFile(".temp-ffmpeg-files-$id", ".txt").apply {
|
||||
writeText(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun concatenateVideoFiles(
|
||||
inputFiles: Iterable<String>,
|
||||
outputFile: String,
|
||||
extraCommand: String = "",
|
||||
onProgress: (Int) -> Unit = { },
|
||||
): CompletableDeferred<Unit> {
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
|
||||
val listFile = createTempFile(inputFiles.joinToString("\n") { "file '$it'" })
|
||||
|
||||
val command =
|
||||
"-protocol_whitelist saf,concat,content,file,subfile" +
|
||||
" -safe 0" +
|
||||
" -strict normal" +
|
||||
" -f concat" +
|
||||
" -i ${listFile.absolutePath}" +
|
||||
extraCommand +
|
||||
" -y" +
|
||||
" $outputFile"
|
||||
|
||||
FFmpegKit.executeAsync(
|
||||
command,
|
||||
{ session ->
|
||||
runCatching {
|
||||
listFile.delete()
|
||||
}
|
||||
|
||||
if (ReturnCode.isSuccess(session!!.returnCode)) {
|
||||
completer.complete(Unit)
|
||||
} else {
|
||||
Log.i(
|
||||
"Video Concatenation",
|
||||
String.format(
|
||||
"Command failed with state %s and rc %s.%s",
|
||||
session.state,
|
||||
session.returnCode,
|
||||
session.failStackTrace,
|
||||
)
|
||||
)
|
||||
|
||||
completer.completeExceptionally(FFmpegException("Failed to concatenate videos"))
|
||||
}
|
||||
},
|
||||
{},
|
||||
{ statistics ->
|
||||
onProgress(statistics.time)
|
||||
}
|
||||
)
|
||||
|
||||
return completer
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegException(message: String) : Exception(message)
|
||||
}
|
@ -0,0 +1,182 @@
|
||||
package app.myzel394.alibi.helpers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateVideoFiles
|
||||
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||
import app.myzel394.alibi.ui.VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class VideoBatchesFolder(
|
||||
override val context: Context,
|
||||
override val type: BatchType,
|
||||
override val customFolder: DocumentFile? = null,
|
||||
override val subfolderName: String = VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||
) : BatchesFolder(
|
||||
context,
|
||||
type,
|
||||
customFolder,
|
||||
subfolderName,
|
||||
) {
|
||||
override val concatenationFunction = ::concatenateVideoFiles
|
||||
override val ffmpegParameters = FFMPEG_PARAMETERS
|
||||
override val scopedMediaContentUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
override val legacyMediaFolder = File(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_RECORDINGS_SUBFOLDER,
|
||||
)
|
||||
|
||||
private var customParcelFileDescriptor: ParcelFileDescriptor? = null
|
||||
|
||||
override fun getOutputFileForFFmpeg(
|
||||
date: LocalDateTime,
|
||||
extension: String,
|
||||
fileName: String,
|
||||
): String {
|
||||
return when (type) {
|
||||
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
||||
|
||||
BatchType.CUSTOM -> {
|
||||
FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
||||
"video/${extension}",
|
||||
fileName,
|
||||
)!!).uri
|
||||
)!!
|
||||
}
|
||||
|
||||
BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val mediaUri = getOrCreateMediaFile(
|
||||
name = fileName,
|
||||
mimeType = "video/$extension",
|
||||
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
||||
)
|
||||
|
||||
return FFmpegKitConfig.getSafParameterForWrite(
|
||||
context,
|
||||
mediaUri
|
||||
)!!
|
||||
} else {
|
||||
val path = arrayOf(
|
||||
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||
MEDIA_SUBFOLDER_NAME,
|
||||
fileName,
|
||||
).joinToString("/")
|
||||
return File(path)
|
||||
.apply {
|
||||
createNewFile()
|
||||
}.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
runCatching {
|
||||
customParcelFileDescriptor?.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun asCustomGetParcelFileDescriptor(
|
||||
counter: Long,
|
||||
fileExtension: String,
|
||||
): ParcelFileDescriptor {
|
||||
runCatching {
|
||||
customParcelFileDescriptor?.close()
|
||||
}
|
||||
|
||||
val file =
|
||||
getCustomDefinedFolder().createFile(
|
||||
"video/$fileExtension",
|
||||
"$counter.$fileExtension"
|
||||
)!!
|
||||
val resolver = context.contentResolver.acquireContentProviderClient(file.uri)!!
|
||||
|
||||
resolver.use {
|
||||
customParcelFileDescriptor = it.openFile(file.uri, "w")!!
|
||||
|
||||
return customParcelFileDescriptor!!
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
fun asMediaGetScopedStorageContentValues(name: String) = ContentValues().apply {
|
||||
put(
|
||||
MediaStore.Video.Media.IS_PENDING,
|
||||
1
|
||||
)
|
||||
put(
|
||||
MediaStore.Video.Media.RELATIVE_PATH,
|
||||
SCOPED_STORAGE_RELATIVE_PATH,
|
||||
)
|
||||
|
||||
put(
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun viaInternalFolder(context: Context) = VideoBatchesFolder(context, BatchType.INTERNAL)
|
||||
|
||||
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
||||
VideoBatchesFolder(context, BatchType.CUSTOM, folder)
|
||||
|
||||
fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA)
|
||||
|
||||
fun importFromFolder(folder: String?, context: Context) = when (folder) {
|
||||
null -> viaInternalFolder(context)
|
||||
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
||||
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
||||
else -> viaCustomFolder(
|
||||
context,
|
||||
DocumentFile.fromTreeUri(context, Uri.parse(folder))!!
|
||||
)
|
||||
}
|
||||
|
||||
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_DCIM
|
||||
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.video_recordings"
|
||||
val BASE_SCOPED_STORAGE_RELATIVE_PATH = Environment.DIRECTORY_DCIM
|
||||
val SCOPED_STORAGE_RELATIVE_PATH =
|
||||
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
||||
|
||||
// Parameters to be passed in descending order
|
||||
// Those parameters first try to concatenate without re-encoding
|
||||
// if that fails, it'll try several fallback methods
|
||||
val FFMPEG_PARAMETERS = arrayOf(
|
||||
" -c copy",
|
||||
" -c:v copy",
|
||||
" -c:v copy -c:a aac",
|
||||
" -c:v copy -c:a libmp3lame",
|
||||
" -c:v copy -c:a libopus",
|
||||
" -c:v copy -c:a libvorbis",
|
||||
" -c:a copy",
|
||||
// There's nothing else we can do to avoid re-encoding,
|
||||
// so we'll just have to re-encode the whole thing
|
||||
" -c:v libx264 -c:a copy",
|
||||
" -c:v libx264 -c:a aac",
|
||||
" -c:v libx265 -c:a aac",
|
||||
" -c:v libx264 -c:a libmp3lame",
|
||||
" -c:v libx264 -c:a libopus",
|
||||
" -c:v libx264 -c:a libvorbis",
|
||||
" -c:v libx265 -c:a copy",
|
||||
" -c:v libx265 -c:a aac",
|
||||
" -c:v libx265 -c:a libmp3lame",
|
||||
" -c:v libx265 -c:a libopus",
|
||||
" -c:v libx265 -c:a libvorbis",
|
||||
)
|
||||
}
|
||||
}
|
@ -1,40 +1,43 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaRecorder
|
||||
import android.media.MediaRecorder.OnErrorListener
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.app.ServiceCompat
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.db.RecordingInformation
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
|
||||
class AudioRecorderService: IntervalRecorderService() {
|
||||
class AudioRecorderService :
|
||||
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
||||
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var amplitudes = mutableListOf<Int>()
|
||||
private set
|
||||
var amplitudesAmount = 1000
|
||||
|
||||
var selectedMicrophone: MicrophoneInfo? = null
|
||||
|
||||
var recorder: MediaRecorder? = null
|
||||
private set
|
||||
|
||||
val filePath: String
|
||||
get() = "$folder/$counter.${settings!!.fileExtension}"
|
||||
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFile(filePath)
|
||||
setOutputFormat(settings!!.outputFormat)
|
||||
setAudioEncoder(settings!!.encoder)
|
||||
setAudioEncodingBitRate(settings!!.bitRate)
|
||||
setAudioSamplingRate(settings!!.samplingRate)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetRecorder() {
|
||||
runCatching {
|
||||
recorder?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Callbacks
|
||||
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||
var onMicrophoneDisconnected: () -> Unit = {}
|
||||
var onMicrophoneReconnected: () -> Unit = {}
|
||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||
|
||||
override fun startNewCycle() {
|
||||
super.startNewCycle()
|
||||
@ -44,9 +47,21 @@ class AudioRecorderService: IntervalRecorderService() {
|
||||
}
|
||||
|
||||
resetRecorder()
|
||||
startAudioDevice()
|
||||
|
||||
newRecorder.start()
|
||||
recorder = newRecorder
|
||||
try {
|
||||
recorder = newRecorder
|
||||
newRecorder.start()
|
||||
} catch (error: RuntimeException) {
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
createAmplitudesTimer()
|
||||
registerMicrophoneListener()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
@ -55,13 +70,246 @@ class AudioRecorderService: IntervalRecorderService() {
|
||||
resetRecorder()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
|
||||
override suspend fun stop() {
|
||||
resetRecorder()
|
||||
unregisterMicrophoneListener()
|
||||
|
||||
super.stop()
|
||||
}
|
||||
|
||||
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||
override fun resume() {
|
||||
super.resume()
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0
|
||||
override fun startForegroundService() {
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
|
||||
getNotificationHelper().buildStartingNotification(),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ==== Amplitude related ====
|
||||
private fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||
|
||||
private fun getAmplitude(): Int {
|
||||
return try {
|
||||
recorder!!.maxAmplitude
|
||||
} catch (error: IllegalStateException) {
|
||||
0
|
||||
} catch (error: RuntimeException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAmplitude() {
|
||||
if (state !== RecorderState.RECORDING) {
|
||||
return
|
||||
}
|
||||
|
||||
amplitudes.add(getAmplitude())
|
||||
onAmplitudeChange?.invoke(amplitudes)
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > getAmplitudeAmount()) {
|
||||
// Should be more efficient than dropping the elements, getting a new list
|
||||
// clearing old list and adding new elements to it
|
||||
repeat(amplitudes.size - getAmplitudeAmount()) {
|
||||
amplitudes.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
private fun createAmplitudesTimer() {
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
// ==== Audio device related ====
|
||||
|
||||
/// Tell Android to use the correct bluetooth microphone, if any selected
|
||||
private fun startAudioDevice() {
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
|
||||
} else {
|
||||
audioManger.startBluetoothSco()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAudioDevice() {
|
||||
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
audioManger.clearCommunicationDevice()
|
||||
} else {
|
||||
audioManger.stopBluetoothSco()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameForMediaFile() =
|
||||
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
|
||||
|
||||
// ==== Actual recording related ====
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
val audioSettings = settings.audioRecorderSettings
|
||||
|
||||
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
||||
// and Redmi Buds 3 Pro:
|
||||
// - MIC: Uses the bottom microphone of the phone (17)
|
||||
// - CAMCORDER: Uses the top microphone of the phone (2)
|
||||
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
||||
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
|
||||
when (batchesFolder.type) {
|
||||
BatchesFolder.BatchType.INTERNAL -> {
|
||||
setOutputFile(
|
||||
batchesFolder.asInternalGetFile(
|
||||
counter,
|
||||
audioSettings.fileExtension
|
||||
).absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.CUSTOM -> {
|
||||
setOutputFile(
|
||||
batchesFolder.asCustomGetFileDescriptor(
|
||||
counter,
|
||||
audioSettings.fileExtension
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BatchesFolder.BatchType.MEDIA -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setOutputFile(
|
||||
batchesFolder.asMediaGetScopedStorageFileDescriptor(
|
||||
getNameForMediaFile(),
|
||||
"audio/${audioSettings.fileExtension}"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val name = getNameForMediaFile()
|
||||
val file = batchesFolder.asMediaGetLegacyFile(name)
|
||||
|
||||
setOutputFile(file.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOutputFormat(audioSettings.getOutputFormat())
|
||||
|
||||
setAudioEncoder(audioSettings.getEncoder())
|
||||
setAudioEncodingBitRate(audioSettings.bitRate)
|
||||
setAudioSamplingRate(audioSettings.getSamplingRate())
|
||||
setOnErrorListener(OnErrorListener { _, _, _ ->
|
||||
onError()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Microphone related ====
|
||||
private fun resetRecorder() {
|
||||
runCatching {
|
||||
recorder?.apply {
|
||||
stop()
|
||||
reset()
|
||||
release()
|
||||
}
|
||||
clearAudioDevice()
|
||||
batchesFolder.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
||||
selectedMicrophone = microphone
|
||||
onSelectedMicrophoneChange(microphone)
|
||||
|
||||
if (state == RecorderState.RECORDING) {
|
||||
startNewCycle()
|
||||
}
|
||||
}
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// We can't compare the ID, as it seems to be changing on each reconnect
|
||||
val newDevice = addedDevices?.find {
|
||||
it.productName == selectedMicrophone!!.deviceInfo.productName &&
|
||||
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
|
||||
it.type == selectedMicrophone!!.deviceInfo.type && (
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
it.address == selectedMicrophone!!.deviceInfo.address
|
||||
} else true
|
||||
)
|
||||
}
|
||||
if (newDevice != null) {
|
||||
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
|
||||
|
||||
onMicrophoneReconnected()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
|
||||
if (selectedMicrophone == null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
||||
onMicrophoneDisconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerMicrophoneListener() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
audioManager.registerAudioDeviceCallback(
|
||||
audioDeviceCallback,
|
||||
Handler(Looper.getMainLooper())
|
||||
)
|
||||
}
|
||||
|
||||
private fun unregisterMicrophoneListener() {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
}
|
||||
|
||||
// ==== Settings ====
|
||||
override fun getRecordingInformation() =
|
||||
RecordingInformation(
|
||||
folderPath = batchesFolder.exportFolderForSettings(),
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings.maxDuration,
|
||||
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
||||
fileExtension = settings.audioRecorderSettings.fileExtension,
|
||||
intervalDuration = settings.intervalDuration,
|
||||
type = RecordingInformation.Type.AUDIO,
|
||||
)
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class ExtraRecorderInformationService: RecorderService() {
|
||||
abstract fun getAmplitudeAmount(): Int
|
||||
abstract fun getAmplitude(): Int
|
||||
|
||||
var amplitudes = mutableListOf<Int>()
|
||||
private set
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||
|
||||
private fun updateAmplitude() {
|
||||
if (state !== RecorderState.RECORDING) {
|
||||
return
|
||||
}
|
||||
|
||||
amplitudes.add(getAmplitude())
|
||||
onAmplitudeChange?.invoke(amplitudes)
|
||||
|
||||
// Delete old amplitudes
|
||||
if (amplitudes.size > getAmplitudeAmount()) {
|
||||
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
|
||||
}
|
||||
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
private fun createAmplitudesTimer() {
|
||||
handler.postDelayed(::updateAmplitude, 100)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createAmplitudesTimer()
|
||||
}
|
||||
|
||||
}
|
@ -1,45 +1,45 @@
|
||||
package app.myzel394.alibi.services
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import app.myzel394.alibi.dataStore
|
||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
||||
import app.myzel394.alibi.db.LastRecording
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executor
|
||||
import app.myzel394.alibi.db.AppSettings
|
||||
import app.myzel394.alibi.helpers.BatchesFolder
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||
private var job = SupervisorJob()
|
||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
protected var counter = 0
|
||||
abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
||||
RecorderService() {
|
||||
protected var counter = 0L
|
||||
private set
|
||||
protected lateinit var folder: File
|
||||
var settings: Settings? = null
|
||||
protected set
|
||||
|
||||
// Tracks the index of the currently locked file
|
||||
private var lockedIndex: Long? = null
|
||||
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private lateinit var cycleTimer: ScheduledExecutorService
|
||||
|
||||
fun createLastRecording(): LastRecording = LastRecording(
|
||||
folderPath = folder.absolutePath,
|
||||
recordingStart = recordingStart,
|
||||
maxDuration = settings!!.maxDuration,
|
||||
fileExtension = settings!!.fileExtension,
|
||||
intervalDuration = settings!!.intervalDuration,
|
||||
forceExactMaxDuration = settings!!.forceExactMaxDuration,
|
||||
)
|
||||
abstract var batchesFolder: B
|
||||
|
||||
var onBatchesFolderNotAccessible: () -> Unit = {}
|
||||
|
||||
abstract fun getRecordingInformation(): I
|
||||
|
||||
// When saving the recording, the files should be locked.
|
||||
// This prevents the service from deleting the currently available files, so that
|
||||
// they can be safely used to save the recording.
|
||||
// Once finished, make sure to unlock the files using `unlockFiles`.
|
||||
fun lockFiles() {
|
||||
lockedIndex = counter
|
||||
}
|
||||
|
||||
// Unlocks and deletes the files that were locked using `lockFiles`.
|
||||
fun unlockFiles(cleanupFiles: Boolean = false) {
|
||||
if (cleanupFiles) {
|
||||
batchesFolder.deleteRecordings(0..<lockedIndex!!)
|
||||
}
|
||||
|
||||
lockedIndex = null
|
||||
}
|
||||
|
||||
// Make overrideable
|
||||
open fun startNewCycle() {
|
||||
@ -50,103 +50,56 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
|
||||
private fun createTimer() {
|
||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||
it.scheduleAtFixedRate(
|
||||
{
|
||||
startNewCycle()
|
||||
},
|
||||
::startNewCycle,
|
||||
0,
|
||||
settings!!.intervalDuration,
|
||||
settings.intervalDuration,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRandomFileFolder(): String {
|
||||
// uuid
|
||||
val folder = UUID.randomUUID().toString()
|
||||
|
||||
return "${externalCacheDir!!.absolutePath}/$folder"
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
folder = File(getRandomFileFolder())
|
||||
folder.mkdirs()
|
||||
batchesFolder.initFolders()
|
||||
|
||||
scope.launch {
|
||||
dataStore.data.collectLatest { preferenceSettings ->
|
||||
if (settings == null) {
|
||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
||||
if (!batchesFolder.checkIfFolderIsAccessible()) {
|
||||
onBatchesFolderNotAccessible()
|
||||
|
||||
createTimer()
|
||||
}
|
||||
}
|
||||
throw AvoidErrorDialogError()
|
||||
}
|
||||
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
cycleTimer.shutdown()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
createTimer()
|
||||
|
||||
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
|
||||
// amplitudes
|
||||
super.resume()
|
||||
createTimer()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
override suspend fun stop() {
|
||||
cycleTimer.shutdown()
|
||||
batchesFolder.cleanup()
|
||||
super.stop()
|
||||
}
|
||||
|
||||
fun clearAllRecordings() {
|
||||
batchesFolder.deleteRecordings()
|
||||
}
|
||||
|
||||
private fun deleteOldRecordings() {
|
||||
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
||||
val earliestCounter = counter - timeMultiplier
|
||||
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
|
||||
|
||||
folder.listFiles()?.forEach { file ->
|
||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||
|
||||
if (fileCounter < earliestCounter) {
|
||||
file.delete()
|
||||
}
|
||||
if (earliestCounter <= 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val forceExactMaxDuration: Boolean,
|
||||
val bitRate: Int,
|
||||
val samplingRate: Int,
|
||||
val outputFormat: Int,
|
||||
val encoder: Int,
|
||||
) {
|
||||
val fileExtension: String
|
||||
get() = when(outputFormat) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||
return Settings(
|
||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||
bitRate = audioRecorderSettings.bitRate,
|
||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||
encoder = audioRecorderSettings.getEncoder(),
|
||||
maxDuration = audioRecorderSettings.maxDuration,
|
||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
batchesFolder.deleteRecordings(0..earliestCounter)
|
||||
}
|
||||
}
|
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# services
|
||||
|
||||
This folder contains all available services.
|
||||
|
||||
## VideoRecorderService
|
||||
|
||||
I found it a bit confusing on how to properly handle the services, so I made this diagram
|
||||
to help me understand it better. I hope it helps you too.
|
||||
|
||||

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